From e0b0c1e8d3a4763e255a45a7fa9eb0ebe1392110 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:19 +1200 Subject: [PATCH 001/343] Bump version to 2026.7.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3537516996b..9f4e20b977f 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.0-dev +PROJECT_NUMBER = 2026.7.0-dev # 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 22351244bd8..3ca7b2e6188 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.0-dev" +__version__ = "2026.7.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6a527c7efc24a3a14b5d29db862810ee830bc7c5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:04:22 +1200 Subject: [PATCH 002/343] [tests] Mock target branch in memory-impact exclusion test (#16913) --- tests/script/test_determine_jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa686..a9defcacac7 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1470,6 +1470,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: From 750cf1995b894a80fcae6c875a0a60d3c56beee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jun 2026 08:47:50 -0500 Subject: [PATCH 003/343] [esp8266] Decode crash handler PC and backtrace in logs (#16911) --- esphome/components/esp8266/__init__.py | 18 ++++++++++- .../components/test_esp_stacktrace.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6d..db94f0ec6d2 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d62..f231ac5fb74 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: From 28dd935359ea59270c18b463f858041eed35ef25 Mon Sep 17 00:00:00 2001 From: Dan Drown Date: Thu, 11 Jun 2026 11:35:44 -0500 Subject: [PATCH 004/343] [xpt2046] touchscreen driver enhancement (#16414) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../xpt2046/touchscreen/xpt2046.cpp | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index d08a54529d8..83a73320054 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -6,6 +6,13 @@ namespace esphome::xpt2046 { +static constexpr uint8_t XPT_READ_Z1 = 0xB0; +static constexpr uint8_t XPT_READ_Z2 = 0xC0; +static constexpr uint8_t XPT_READ_X = 0xD0; +static constexpr uint8_t XPT_READ_Y = 0x90; +static constexpr uint8_t XPT_ADC_ON = 0x01; +static constexpr uint8_t XPT_VREF_ON = 0x02; + static const char *const TAG = "xpt2046"; void XPT2046Component::setup() { @@ -20,7 +27,7 @@ void XPT2046Component::setup() { this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE); } this->spi_setup(); - this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin + this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin } void XPT2046Component::update_touches() { @@ -29,21 +36,22 @@ void XPT2046Component::update_touches() { enable(); - int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */); - int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */); + int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON); + int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON); z_raw = touch_pressure_1 + 0xfff - touch_pressure_2; ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw); touch = (z_raw >= this->threshold_); if (touch) { - read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy - data[0] = this->read_adc_(0x91 /* Y */); - data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements - data[2] = this->read_adc_(0x91 /* Y */); - data[3] = this->read_adc_(0xD1 /* X */); - data[4] = this->read_adc_(0x91 /* Y */); + read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy + // make 3 x-y measurements + data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); } - data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down + data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down disable(); @@ -95,15 +103,16 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v return reta; } -int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT - uint8_t data[2]; +int16_t XPT2046Component::read_adc_(uint8_t ctrl) { + uint8_t data[3]; - this->write_byte(ctrl); - delay(1); - data[0] = this->read_byte(); - data[1] = this->read_byte(); + data[0] = ctrl; + data[1] = 0; + data[2] = 0; - return ((data[0] << 8) | data[1]) >> 3; + this->transfer_array(data, sizeof(data)); + + return ((data[1] << 8) | data[2]) >> 3; } } // namespace esphome::xpt2046 From 6ef35b6d3d163d0d027866b10805c617a841bfdc Mon Sep 17 00:00:00 2001 From: Tobiasz Jakubowski <12734857+tjakubo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:50:51 +0200 Subject: [PATCH 005/343] [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/spi/spi_esp_idf.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1ae..0731078eeca 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); From 88084f2ec712ef015c51feb57f1d0bbaf7955737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:32:51 -0400 Subject: [PATCH 006/343] Bump ruff from 0.15.16 to 0.15.17 (#16918) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9da27acc19a..5ba806a2f57 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.16 # also change in .pre-commit-config.yaml when updating +ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From bf6c8568d364b8c2d76c29aba756c9ebd4651ab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:33:28 -0400 Subject: [PATCH 007/343] Bump CodSpeedHQ/action from 4.17.0 to 4.17.5 (#16919) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57be34e9b4..deeec720955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 + uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 with: run: | . venv/bin/activate From 10ce6024bf2339b888a5182aea1230634d789d69 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:21:38 +1000 Subject: [PATCH 008/343] [lvgl] Fix schema extraction (#16895) Co-authored-by: Claude Opus 4.8 --- esphome/components/lvgl/__init__.py | 175 ++++++++++++--------- esphome/components/lvgl/schemas.py | 48 +++++- script/build_language_schema.py | 28 ++++ tests/script/test_build_language_schema.py | 107 +++++++++++++ 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960b..9137412abe5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c4..d7df6289071 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( def get_schema() -> cv.Schema: nonlocal cached_schema if cached_schema is None: - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - cached_schema = schema.extend(widget_type.schema) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548c..61845c4b25d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fefe..badd4686f68 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" From 35e5c7c7c353ab8182d3c74a65b434e1738ec2e3 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sat, 13 Jun 2026 23:40:49 +0200 Subject: [PATCH 009/343] [runtime_image] Improve error logging (#16943) --- esphome/components/online_image/online_image.cpp | 3 ++- esphome/components/runtime_image/image_decoder.h | 16 ++++++++++++++++ .../components/runtime_image/jpeg_decoder.cpp | 16 ++++++++++++++-- esphome/components/runtime_image/png_decoder.cpp | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a5a3ea51041..22bce4cc418 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,4 +1,5 @@ #include "online_image.h" +#include "esphome/components/runtime_image/image_decoder.h" #include "esphome/core/log.h" #include @@ -181,7 +182,7 @@ void OnlineImage::loop() { auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed < 0) { - ESP_LOGE(TAG, "Error decoding image: %d", consumed); + ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed)); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 926108a8a0e..c68ea5720b6 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -7,8 +7,24 @@ enum DecodeError : int { DECODE_ERROR_INVALID_TYPE = -1, DECODE_ERROR_UNSUPPORTED_FORMAT = -2, DECODE_ERROR_OUT_OF_MEMORY = -3, + DECODE_ERROR_INTERNAL_DECODER_ERROR = -4, }; +constexpr const char *decode_error_to_string(int error) { + switch (error) { + case DECODE_ERROR_INVALID_TYPE: + return "Invalid type"; + case DECODE_ERROR_UNSUPPORTED_FORMAT: + return "Unsupported format"; + case DECODE_ERROR_OUT_OF_MEMORY: + return "Out of memory"; + case DECODE_ERROR_INTERNAL_DECODER_ERROR: + return "Internal decoder error"; + default: + return "Unknown error"; + } +} + class RuntimeImage; /** diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index dcaa07cd58c..c46e86fd0d9 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -89,9 +89,21 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { return DECODE_ERROR_OUT_OF_MEMORY; } if (!this->jpeg_.decode(0, 0, 0)) { - ESP_LOGE(TAG, "Error while decoding."); + auto error = this->jpeg_.getLastError(); + ESP_LOGE(TAG, "Error while decoding: %d", error); this->jpeg_.close(); - return DECODE_ERROR_UNSUPPORTED_FORMAT; + switch (error) { + case JPEG_ERROR_MEMORY: + return DECODE_ERROR_OUT_OF_MEMORY; + case JPEG_UNSUPPORTED_FEATURE: + return DECODE_ERROR_UNSUPPORTED_FORMAT; + case JPEG_INVALID_FILE: + case JPEG_INVALID_PARAMETER: + return DECODE_ERROR_INVALID_TYPE; + case JPEG_DECODE_ERROR: + default: + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } } this->decoded_bytes_ = size; this->jpeg_.close(); diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 591504328d8..12bce0d284f 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -95,6 +95,7 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { auto fed = pngle_feed(this->pngle_, buffer, size); if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); + return DECODE_ERROR_INTERNAL_DECODER_ERROR; } else { this->decoded_bytes_ += fed; } From 5b7f8cf90d0d78a0563cd342b718fa6fd75992e5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:36:38 +1000 Subject: [PATCH 010/343] [mipi_spi] Implement automatic mapping of offsets (#16722) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mipi/__init__.py | 131 ++++-- esphome/components/mipi_dsi/display.py | 8 +- esphome/components/mipi_rgb/display.py | 8 +- esphome/components/mipi_spi/display.py | 36 +- esphome/components/mipi_spi/mipi_spi.h | 87 ++-- esphome/components/mipi_spi/models/ili.py | 28 ++ .../components/mipi_spi/models/waveshare.py | 13 + tests/component_tests/mipi_spi/test_init.py | 4 +- .../mipi_spi/test_padding_and_offsets.py | 434 ++++++++++++++++++ 9 files changed, 662 insertions(+), 87 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_padding_and_offsets.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919a..129befe600d 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a79..896140b4b19 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726a..1eacc31fc58 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff5005..abb7eaa4585 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf80891..a594e482098 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb9073..5df7a275dff 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de1..3c719b0f5e2 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d8..d681908027d 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 00000000000..82adf88b7e0 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 From 1e5771a3fa446c0de961a9a667efc19c8002ec5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:48:43 -0400 Subject: [PATCH 011/343] [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) --- .clang-tidy.hash | 2 +- esphome/components/esp32/pre_build.py.script | 7 +++++++ esphome/espidf/clang_tidy.py | 2 +- esphome/idf_component.yml | 2 +- platformio.ini | 18 +++++++++++++----- tests/unit_tests/test_espidf_clang_tidy.py | 6 +++--- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 6f6339ff84c..7497cc3679f 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 +a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b1..8728e02a346 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d3..d3f4d151c21 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at # reconfigure time). Set here -- before the manifest is written/reconfigured. - os.environ["ESPHOME_ARDUINO"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4aef..c97e8906a8c 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/platformio.ini b/platformio.ini index 718dfb672f6..862b7a7dbe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543c..cb25535d8d2 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps.""" # monkeypatch snapshots os.environ, so the env var _setup_core writes is # restored after the test instead of leaking into later tests. - monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected From e191fc5d47284c2e0609c4fe368847d1fb33e79f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:03 -0400 Subject: [PATCH 012/343] [core] Support platformio_options on the native ESP-IDF toolchain (#16917) --- esphome/core/__init__.py | 7 + esphome/core/config.py | 66 ++++++++-- esphome/espidf/component.py | 55 ++++++-- tests/unit_tests/core/test_config.py | 123 ++++++++++++++++++ .../fixtures/core/config/libraries.yaml | 8 ++ tests/unit_tests/test_core.py | 18 +++ tests/unit_tests/test_espidf_component.py | 122 ++++++++++++++++- 7 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/config/libraries.yaml diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 4289cdf3e52..21ff7ef07c7 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -958,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80cb..b925f0b7d96 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36a..cfd42916b2b 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, is_git, locator = _node_key(name, version, repository) node = nodes.get(key) or _LibNode(key=key, is_git=is_git) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: dep_url, dep_version = dep_version, None except (TypeError, ValueError): pass - dep_key = add_spec( - _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), - dep_version, - dep_url, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f25408..e2b34d92d82 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 00000000000..c93e828f317 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9d..a61b6ae7aec 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff039422..87e168dc94b 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") From efebea32969ba72ce34524798f057064ffb7f766 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:56:18 -0400 Subject: [PATCH 013/343] [esp32] Add flash_mode and flash_frequency config options (#16920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 24 +++++++++++++++++ .../esp32/config/flash_mode_default.yaml | 7 +++++ .../esp32/config/flash_mode_idf.yaml | 9 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_default.yaml create mode 100644 tests/component_tests/esp32/config/flash_mode_idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b1278147..d703e22e462 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1615,8 +1615,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1866,6 +1876,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2032,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 00000000000..0d051420994 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 00000000000..7c7f50a4399 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d42..a8b5720a80b 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options From f1fd5f2f4957849602f7903c7d70dc57e119671e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:10:58 +1000 Subject: [PATCH 014/343] [epaper_spi] Metadata, bug fixes, new model (#16950) --- esphome/components/epaper_spi/display.py | 22 +- esphome/components/epaper_spi/epaper_spi.cpp | 4 + esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/models/ssd1677.py | 17 +- tests/component_tests/conftest.py | 38 ++++ .../epaper_spi/config/enable_pin_test.yaml | 24 +++ .../epaper_spi/test_display_metadata.py | 156 ++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 190 ++++++++++++++---- tests/component_tests/mipi_spi/conftest.py | 39 +--- 9 files changed, 412 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/epaper_spi/config/enable_pin_test.yaml create mode 100644 tests/component_tests/epaper_spi/test_display_metadata.py diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7c56a283a7..ce28fb0d67e 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -13,6 +13,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BUSY_PIN, CONF_CS_PIN, CONF_DATA_RATE, @@ -129,7 +130,23 @@ def customise_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + model = MODELS[config[CONF_MODEL]] + config = model_schema(config)(config) + width, height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=True, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=0, + ) + return config CONFIG_SCHEMA = customise_schema @@ -197,6 +214,9 @@ async def to_code(config): if busy_pin := config.get(CONF_BUSY_PIN): busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + if enable_pin := config.get(CONF_ENABLE_PIN): + enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] + cg.add(var.set_enable_pins(enable)) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a2ca311b305..3214f932bfb 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -38,6 +38,10 @@ bool EPaperBase::init_buffer_(size_t buffer_length) { } void EPaperBase::setup_pins_() const { + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } this->dc_pin_->setup(); // OUTPUT this->dc_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 2992ca5afda..8e2fd78e621 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -50,6 +50,7 @@ class EPaperBase : public Display, float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } + void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } void set_transform(uint8_t transform) { this->transform_ = transform; @@ -177,6 +178,7 @@ class EPaperBase : public Display, GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + std::vector enable_pins_{}; bool waiting_for_idle_{}; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index bad33a6a023..13f10350457 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -10,11 +10,11 @@ class SSD1677(EpaperModel): # fmt: off def get_init_sequence(self, config: dict): - width, _height = self.get_dimensions(config) + _width, height = self.get_dimensions(config) return ( (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 - (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) (0x3C, 0x01), # Set border waveform (0x11, 3), # Set transform ) @@ -51,3 +51,16 @@ ssd1677.extend( height=480, mirror_x=True, ) + +ssd1677.extend( + "seeed-reterminal-sticky", + width=800, + height=480, + mirror_x=True, + enable_pin=47, + cs_pin=15, + dc_pin=16, + reset_pin=17, + busy_pin=18, + data_rate="10MHz", +) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 763628f57c9..3730978ec3c 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]: return setter +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """Set the ESP32 variant to the first one on which all the given pins are valid. + + For ESP32 only, since the other platforms do not have variants. The core + configuration must already have been set up for an ESP32 target. + Using local imports to avoid importing when ESP32 is not the target. + """ + from esphome import config_validation as cv + from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS + from esphome.components.esp32.gpio import validate_gpio_pin + from esphome.const import CONF_INPUT, CONF_OUTPUT + from esphome.pins import gpio_pin_schema + + def chooser(pins: list) -> None: + for variant in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser + + @pytest.fixture def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: """Return a function to get absolute paths relative to the component's fixtures directory.""" diff --git a/tests/component_tests/epaper_spi/config/enable_pin_test.yaml b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml new file mode 100644 index 00000000000..d238cd1d9e3 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + enable_pin: + - GPIO25 + - GPIO26 + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_display_metadata.py b/tests/component_tests/epaper_spi/test_display_metadata.py new file mode 100644 index 00000000000..95afefcf354 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_display_metadata.py @@ -0,0 +1,156 @@ +"""Tests for display metadata created by the epaper_spi component.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from esphome import config_validation as cv +from esphome.components.display import get_all_display_metadata, get_display_metadata +from esphome.components.epaper_spi.display import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _base_config(**overrides: Any) -> ConfigType: + """Build a minimal valid ssd1677 config, allowing field overrides.""" + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 300}, + } + config.update(overrides) + return config + + +def test_metadata_dimensions_and_defaults( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Metadata picks up explicit dimensions and epaper_spi defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config()) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 200 + assert meta.height == 300 + # epaper_spi always reports full hardware rotation + assert meta.has_hardware_rotation is True + # epaper_spi does not declare a byte order + assert meta.byte_order is cv.UNDEFINED + assert meta.draw_rounding == 0 + # no drawing methods configured -> no writer + assert meta.has_writer is False + + +def test_metadata_default_dimensions_from_model( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model with built-in dimensions reports those without explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + # waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions + config = CONFIG_SCHEMA( + { + "id": "wave_display", + "model": "waveshare-4.26in", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + } + ) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 800 + assert meta.height == 480 + + +def test_metadata_has_writer_with_auto_clear( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A display with auto_clear_enabled reports has_writer=True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_rotation_propagated( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The configured rotation is stored in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(rotation=90)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Each display gets its own independent metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300})) + CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480})) + + all_meta = get_all_display_metadata() + assert all_meta["disp_a"].width == 200 + assert all_meta["disp_a"].height == 300 + assert all_meta["disp_b"].width == 400 + assert all_meta["disp_b"].height == 480 + + +def test_metadata_via_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Full code generation registers metadata for the configured display.""" + generate_main(component_config_path("enable_pin_test.yaml")) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + # enable_pin_test.yaml: ssd1677 at 200x200 + assert meta.width == 200 + assert meta.height == 200 + assert meta.has_hardware_rotation is True diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index a9f5735fcab..c7f34d7dd26 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -1,6 +1,8 @@ """Tests for epaper_spi configuration validation.""" from collections.abc import Callable +from pathlib import Path +import re from typing import Any import pytest @@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import ( - KEY_BOARD, - KEY_VARIANT, - VARIANT_ESP32, - VARIANT_ESP32S3, -) +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_ENABLE_PIN, CONF_HEIGHT, CONF_INIT_SEQUENCE, CONF_RESET_PIN, @@ -31,6 +29,30 @@ from esphome.const import ( from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable +# Pin options whose values must be valid on the chosen ESP32 variant. +_PIN_CONF_KEYS = ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_RESET_PIN, + CONF_BUSY_PIN, + CONF_ENABLE_PIN, +) + + +def _pins_for(model: Any, config: ConfigType) -> list: + """Collect every GPIO the config will actually use (model defaults or injected).""" + pins: list = [] + for key in _PIN_CONF_KEYS: + # An injected value in the config takes precedence over the model default. + value = config[key] if key in config else model.get_default(key) + if not value: # get_default returns False for pins the model omits + continue + if isinstance(value, list): + pins.extend(value) + else: + pins.append(value) + return pins + def run_schema_validation( config: ConfigType, with_final_validate: bool = False @@ -90,29 +112,20 @@ def test_basic_configuration_errors( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined epaper models validate successfully with appropriate defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + # Test all models, providing default values where necessary for name, model in MODELS.items(): - # SEEED models are designed for ESP32-S3 hardware - if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - - # Configure SPI component which is required by epaper_spi - set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - config = {"model": name} # Add ID field @@ -141,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + run_schema_validation(config) @@ -152,27 +169,19 @@ def test_individual_models( model_name: str, set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test each epaper model individually to ensure it validates correctly.""" - # SEEED models are designed for ESP32-S3 hardware - if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) + model = MODELS[model_name] + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) # Configure SPI component which is required by epaper_spi set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - model = MODELS[model_name] config: dict[str, Any] = {"model": model_name, "id": "test_display"} # Add required fields based on model defaults @@ -195,6 +204,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + # This should not raise any exceptions run_schema_validation(config) @@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677( reset_pin_config = result[CONF_RESET_PIN] assert "mode" in reset_pin_config assert reset_pin_config["mode"]["output"] is True + + +def test_enable_pin_single( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a single enable_pin is accepted and normalised to a list of output pins.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": 25, + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + # A single pin is normalised to a list by cv.ensure_list + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 1 + # enable pins are configured as outputs + assert enable_pins[0]["mode"]["output"] is True + + +def test_enable_pin_multiple( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a list of enable_pins is accepted.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": [25, 26], + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 2 + assert all(pin["mode"]["output"] is True for pin in enable_pins) + + +def test_enable_pin_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that enable_pins are wired up in the generated C++ code.""" + main_cpp = generate_main(component_config_path("enable_pin_test.yaml")) + + # Derive the auto-generated pin variable names from the set_pin() lines + # rather than hard-coding them, so the test does not break when unrelated + # codegen details shift the generated IDs. + def pin_var_for(gpio_num: int) -> str: + match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp) + assert match is not None, ( + f"GPIO_NUM_{gpio_num} pin not set up in generated code" + ) + return match.group(1) + + pin_25 = pin_var_for(25) + pin_26 = pin_var_for(26) + + # Both pin objects must be passed to the display via set_enable_pins() as a + # std::vector initializer list, in the configured order. + assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index 082a9e55f2a..ed48056f63d 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,16 +1,10 @@ """Tests for mpip_spi configuration validation.""" -from collections.abc import Callable, Generator from unittest import mock import pytest -from esphome import config_validation as cv -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS -from esphome.components.esp32.gpio import validate_gpio_pin -from esphome.const import CONF_INPUT, CONF_OUTPUT -from esphome.core import CORE -from esphome.pins import gpio_pin_schema +# choose_variant_with_pins is provided by the shared parent conftest. @pytest.fixture(autouse=True) @@ -21,34 +15,3 @@ def mock_spi_final_validate(): return_value=lambda config: None, ): yield - - -@pytest.fixture -def choose_variant_with_pins() -> Generator[Callable[[list], None]]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(pins: list) -> None: - for variant in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - for pin in pins: - if pin is not None: - pin = gpio_pin_schema( - { - CONF_INPUT: True, - CONF_OUTPUT: True, - }, - internal=True, - )(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - raise cv.Invalid( - f"No compatible variant found for pins: {', '.join(map(str, pins))}" - ) - - yield chooser From c1a7a8ff55e2384e89a9958c0bec3e6b69ba31c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:01:44 +1200 Subject: [PATCH 015/343] Add PEP 572 walrus operator preference to coding conventions (#16951) --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4adc53cae97..4346ffbdae0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,19 @@ This document provides essential context for AI models interacting with this pro - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations +* **Python Idioms:** + * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: + ```python + # Bad - looks up CONF_BLAH twice + if CONF_BLAH in config: + cg.add(var.set_blah(config[CONF_BLAH])) + + # Good - single lookup, value bound inline + if (blah := config.get(CONF_BLAH)) is not None: + cg.add(var.set_blah(blah)) + ``` + The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line. + * **C++ Field Visibility:** * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`. * **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants: From 1ee49720c7fe4a112b8b7604a13cdf2044fae965 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 016/343] [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 d703e22e462..5d4b3b8b476 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 d36d900997d..296ea6c08c7 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 61845c4b25d..974957245a7 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 0924e66adc9..ea4adc69a99 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 00000000000..3fa6360d144 --- /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 00000000000..9c04284163a --- /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 00000000000..3e5899061f7 --- /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 badd4686f68..8bbaa2773aa 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 963465a0a6977db8693057b7609de64ffde613a6 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 017/343] [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 2298f76cd41..53fac9b5349 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 3420cff31647983904e4bd6289aed972fcd8142f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 018/343] [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 888d48e6728..1e4910453a9 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 847fab02bd2..6cd33e566cc 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 369c970d46d..7c12a66b2cf 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 dcad7c9d2e7..cf8b05a3009 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 2d80301897b..7ef5ff50a53 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 ff10f1a8f16..299a5f72eaa 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 bc1d55a1c4b..7297d223572 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 a7c624486db..15bb9ea2398 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 b640aa86fea..378c0fb94b7 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 00000000000..594ec46afb5 --- /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 00000000000..2d8a62f25b6 --- /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 00000000000..860fa00c374 --- /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 00000000000..699a5bc746c --- /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 7a2657cea19b5ce831b52a2682f6bae5fb62bfd7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 019/343] [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 7497cc3679f..7a3cfc7a03b 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 2ddce577ef4..2aceff0c97e 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 c97e8906a8c..04220488cc3 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 a7a407c22c255f0cb4e3bb5014415e4268a60327 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 020/343] [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 bf14514636c..c8ffc02131a 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 5898492a50e..96f1abdb924 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 cf1288d90c7..4d88cbd2264 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 10e83281f04..ef7c5cc8e9f 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 73f839437ea4450b786ca6d767def371f7bdc015 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 021/343] [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 2081264b911..494c0cebe80 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 25de9472b63..c360ae1a4a2 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 bb6cd97948206d38469eba878e53a806292afaa0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:15:12 -0400 Subject: [PATCH 022/343] Bump clang-tidy from 22.1.0.1 to 22.1.7 (#16984) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- requirements_dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7a3cfc7a03b..1f709bb90d7 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 +007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 diff --git a/requirements_dev.txt b/requirements_dev.txt index 31463e07c37..7e66c7244d6 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.0.1 +clang-tidy==22.1.7 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating From b09a5f9e43efd49abed4d7a2845758d2f37fd257 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 023/343] [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 2a40675f3b1..7d4b8503567 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 4d093cf88df..475986e905a 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 00000000000..34bcc4e714e --- /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 d8fa0e414093cc8625ec6f4ce538bd4352b8d56f 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 024/343] [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 2fef3faf8de..c622a2dd365 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 733474c9c9d..ef7e2d0b93f 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 c81420e6cab..c97df812e34 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 8849ea8bc89..b2309439f98 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 efc2d8e42a3..70c4b900823 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 a37b19f5841..568b43a2595 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 930cf2b5b94dcf8143aa4a5afd236b72ee1cc668 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 025/343] [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 c360ae1a4a2..c7634cf1c8f 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 1b9224244ca..18baf40c29b 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 b9904697626..00000000000 --- 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 7c7cdef2d1a..00000000000 --- 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 a1ebb5079ad..00000000000 --- 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 debdf83a8c0..00000000000 --- 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 e6789cbb9bf..00000000000 --- 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 8e782bdc885..00000000000 --- 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 497427596de..00000000000 --- 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 85ad51be5f2..00000000000 --- 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 4fb0ca3f90f..00000000000 --- 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 105ddde7105..00000000000 --- 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 e69de29bb2d..00000000000 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 111157d3015..bb36cfcdb4f 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 6e0f8fe23a4..da450c25f99 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 b8251e8e018..00000000000 --- 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 5883cff0cd1..00000000000 --- 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 e69de29bb2d..00000000000 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 e69de29bb2d..00000000000 From ce11d38c9bac04b1dfe5570cb289399baff6e6f0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:53:11 -0400 Subject: [PATCH 026/343] [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) --- .clang-tidy.hash | 2 +- esphome/components/esp32_hosted/__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 1f709bb90d7..591ce70a628 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -007cddcd7aa933f0ff9b3fd65f0b7571579ac223d11c6117af2b291bd2f9fe74 +6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9f..7f420f27d8c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 04220488cc3..5f3000e52d0 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From 29e8949e3e66c1cea04b5a16ab32b87eb539bd6f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:46 -0400 Subject: [PATCH 027/343] [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) --- esphome/components/ota/ota_backend_esp_idf.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1fb..ac765d8018f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { From e80461eba972acaad9e0592a912948c9855e7f83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:28:27 -0500 Subject: [PATCH 028/343] Bump bundled esphome-device-builder to 1.0.3 (#17005) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c7634cf1c8f..8e7580490f8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 RUN \ platformio settings set enable_telemetry No \ From 009c6dd9957df088017cae2e34617728f794d1d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:38:23 -0500 Subject: [PATCH 029/343] Bump bundled esphome-device-builder to 1.0.4 (#17013) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e7580490f8..185a0740ed9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 RUN \ platformio settings set enable_telemetry No \ From 40d0cbee3fea57b43eb3dfd7d8181588b1efef56 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:07:36 -0500 Subject: [PATCH 030/343] Bump bundled esphome-device-builder to 1.0.5 (#17014) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 185a0740ed9..980791013f7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 RUN \ platformio settings set enable_telemetry No \ From 900e0b8566a535b58a4ce14a9b07c720bcedf71f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:50:56 -0500 Subject: [PATCH 031/343] Bump bundled esphome-device-builder to 1.0.6 (#17016) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 980791013f7..706dd93e671 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 RUN \ platformio settings set enable_telemetry No \ From 0f5defa67eebccbbca0b997d8e4fd3ec0192b8a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:51:08 -0500 Subject: [PATCH 032/343] Bump tzlocal from 5.3.1 to 5.4.3 (#17015) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a825cd9bff8..4ef3df60ffc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 tornado==6.5.7 -tzlocal==5.3.1 # from time +tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 6d9490b5a361459c1f5b1009e372a46a310fed9b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:53:10 -0500 Subject: [PATCH 033/343] Bump bundled esphome-device-builder to 1.0.7 (#17018) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 706dd93e671..b48ba64aa8e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 RUN \ platformio settings set enable_telemetry No \ From ae7c800de826aee6a0a8aa548c7c93c96dd484a8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:32:37 -0500 Subject: [PATCH 034/343] Bump bundled esphome-device-builder to 1.0.8 (#17020) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48ba64aa8e..c199f2edbd5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 RUN \ platformio settings set enable_telemetry No \ From 77a99bceb2739a3cd8e857e705fc9d22299c8ed9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:38:39 -0500 Subject: [PATCH 035/343] Bump bundled esphome-device-builder to 1.0.9 (#17021) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c199f2edbd5..18a99037351 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 RUN \ platformio settings set enable_telemetry No \ From 7cb6cf2f2a46436117c370ce303dda2055fc6e38 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:12:39 -0400 Subject: [PATCH 036/343] [ci] Replace clang-tidy hash with direct config-file diff check (#17019) --- .clang-tidy.hash | 1 - .github/workflows/ci-clang-tidy-hash.yml | 76 ----- .github/workflows/ci.yml | 38 +-- .pre-commit-config.yaml | 9 +- script/ci-custom.py | 2 +- script/clang_tidy_hash.py | 208 +++----------- script/determine-jobs.py | 65 ++--- tests/script/test_clang_tidy_hash.py | 351 +++-------------------- tests/script/test_determine_jobs.py | 48 ++-- 9 files changed, 124 insertions(+), 674 deletions(-) delete mode 100644 .clang-tidy.hash delete mode 100644 .github/workflows/ci-clang-tidy-hash.yml mode change 100755 => 100644 script/clang_tidy_hash.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 591ce70a628..00000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -6765760d573967b853b1f790f0f5478135d12f2b15ffa8bee9b0314090b582ee diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml deleted file mode 100644 index 73c437467b5..00000000000 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Clang-tidy Hash CI - -on: - pull_request: - paths: - - ".clang-tidy" - - "platformio.ini" - - "requirements_dev.txt" - - "sdkconfig.defaults" - - ".clang-tidy.hash" - - "script/clang_tidy_hash.py" - - ".github/workflows/ci-clang-tidy-hash.yml" - -permissions: - contents: read # actions/checkout for the PR head - pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date - -jobs: - verify-hash: - name: Verify clang-tidy hash - runs-on: ubuntu-latest - steps: - - name: Checkout - 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: Verify hash - run: | - python script/clang_tidy_hash.py --verify - - - if: failure() - name: Show hash details - run: | - python script/clang_tidy_hash.py - echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY - echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY - echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY - - - if: failure() && github.event.pull_request.head.repo.full_name == github.repository - name: Request changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - await github.rest.pulls.createReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - event: 'REQUEST_CHANGES', - body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' - }) - - - if: success() && github.event.pull_request.head.repo.full_name == github.repository - name: Dismiss review - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - let reviews = await github.rest.pulls.listReviews({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo - }); - for (let review of reviews.data) { - if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { - await github.rest.pulls.dismissReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - review_id: review.id, - message: 'Clang-tidy hash now matches configuration.' - }); - } - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deeec720955..1b1032bcde7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,15 +537,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -607,15 +604,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -691,15 +685,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -779,15 +770,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -1049,7 +1037,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash,ci-custom + SKIP: pylint,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6278e6b5e..ba74aff07cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit # Skip hooks that have issues in pre-commit CI environment - skip: [pylint, clang-tidy-hash] + skip: [pylint] repos: - repo: https://github.com/astral-sh/ruff-pre-commit @@ -59,13 +59,6 @@ repos: language: system types: [python] files: ^esphome/.+\.py$ - - id: clang-tidy-hash - name: Update clang-tidy hash - entry: python script/clang_tidy_hash.py --update-if-changed - language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ - pass_filenames: false - additional_dependencies: [] - id: ci-custom name: ci-custom entry: python script/run-in-env.py script/ci-custom.py diff --git a/script/ci-custom.py b/script/ci-custom.py index 78ff6cf781c..cbc54ce55d3 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -276,7 +276,7 @@ def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." -@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"]) +@lint_content_check(exclude=["*.svg"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py old mode 100755 new mode 100644 index 62f76246b4c..00bcaf45b01 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -1,66 +1,32 @@ -#!/usr/bin/env python3 -"""Calculate and manage hash for clang-tidy configuration.""" +"""Files that affect clang-tidy results, and a content hash over them. + +``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single +source of truth for which files influence clang-tidy output. A change to any of +them can surface warnings in source files a PR didn't touch, so: + +* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and +* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by + ``script/helpers.py`` (a content hash, unlike an mtime check, stays correct + across git checkouts). +""" from __future__ import annotations -import argparse import hashlib from pathlib import Path -import re -import sys -# Add the script directory to path to import helpers -script_dir = Path(__file__).parent -sys.path.insert(0, str(script_dir)) +# Root-relative paths whose contents affect clang-tidy results. +CLANG_TIDY_GLOBAL_FILES = ( + ".clang-tidy", + "platformio.ini", + "requirements_dev.txt", + "esphome/idf_component.yml", +) - -def read_file_lines(path: Path) -> list[str]: - """Read lines from a file.""" - with path.open() as f: - return f.readlines() - - -def parse_requirement_line(line: str) -> tuple[str, str] | None: - """Parse a requirement line and return (package, original_line) or None. - - Handles formats like: - - package==1.2.3 - - package==1.2.3 # comment - - package>=1.2.3,<2.0.0 - """ - original_line = line.strip() - - # Extract the part before any comment for parsing - parse_line = line - if "#" in parse_line: - parse_line = parse_line[: parse_line.index("#")] - - parse_line = parse_line.strip() - if not parse_line: - return None - - # Use regex to extract package name - # This matches package names followed by version operators - match = re.match(r"^([a-zA-Z0-9_-]+)(==|>=|<=|>|<|!=|~=)(.+)$", parse_line) - if match: - return (match.group(1), original_line) # Return package name and original line - - return None - - -def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> str: - """Get clang-tidy version from requirements_dev.txt""" - repo_root = _ensure_repo_root(repo_root) - requirements_path = repo_root / "requirements_dev.txt" - lines = read_file_lines(requirements_path) - - for line in lines: - parsed = parse_requirement_line(line) - if parsed and parsed[0] == "clang-tidy": - # Return the original line (preserves comments) - return parsed[1] - - return "clang-tidy version not found" +# sdkconfig.defaults and per-target sdkconfig.defaults. files flip the +# CONFIG flags that decide which variant code paths clang-tidy sees. Matched by +# this prefix at the repo root. +SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults" def read_file_bytes(path: Path) -> bytes: @@ -80,130 +46,20 @@ def _ensure_repo_root(repo_root: Path | None) -> Path: def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: - """Calculate hash of clang-tidy configuration and version""" + """Calculate a hash of the files that affect clang-tidy results.""" repo_root = _ensure_repo_root(repo_root) hasher = hashlib.sha256() - # Hash .clang-tidy file - clang_tidy_path = repo_root / ".clang-tidy" - content = read_file_bytes(clang_tidy_path) - hasher.update(content) + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + if path.exists(): + hasher.update(read_file_bytes(path)) - # Hash clang-tidy version from requirements_dev.txt - version = get_clang_tidy_version_from_requirements(repo_root) - hasher.update(version.encode()) - - # Hash the entire platformio.ini file - platformio_path = repo_root / "platformio.ini" - platformio_content = read_file_bytes(platformio_path) - hasher.update(platformio_content) - - # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: - # the per-target files flip CONFIG flags that change which variant code - # paths clang-tidy sees. Include the filename so a rename is detected. - for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): - hasher.update(sdkconfig_path.name.encode()) - hasher.update(read_file_bytes(sdkconfig_path)) - - # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF - # build's include set, which clang-tidy analyzes. - idf_component_path = repo_root / "esphome" / "idf_component.yml" - if idf_component_path.exists(): - hasher.update(read_file_bytes(idf_component_path)) + # Hash each sdkconfig.defaults* file. Include the filename so adding or + # renaming a per-target variant is detected, not just content edits. + for path in sorted(repo_root.glob(f"{SDKCONFIG_DEFAULTS_PREFIX}*")): + hasher.update(path.name.encode()) + hasher.update(read_file_bytes(path)) return hasher.hexdigest() - - -def read_stored_hash(repo_root: Path | None = None) -> str | None: - """Read the stored hash from file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - if hash_file.exists(): - lines = read_file_lines(hash_file) - return lines[0].strip() if lines else None - return None - - -def write_file_content(path: Path, content: str) -> None: - """Write content to a file.""" - with path.open("w") as f: - f.write(content) - - -def write_hash(hash_value: str, repo_root: Path | None = None) -> None: - """Write hash to file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - # Strip any trailing newlines to ensure consistent formatting - write_file_content(hash_file, hash_value.strip() + "\n") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage clang-tidy configuration hash") - parser.add_argument( - "--check", - action="store_true", - help="Check if full scan needed (exit 0 if needed)", - ) - parser.add_argument("--update", action="store_true", help="Update the hash file") - parser.add_argument( - "--update-if-changed", - action="store_true", - help="Update hash only if configuration changed (for pre-commit)", - ) - parser.add_argument( - "--verify", action="store_true", help="Verify hash matches (for CI)" - ) - - args = parser.parse_args() - - current_hash = calculate_clang_tidy_hash() - stored_hash = read_stored_hash() - - if args.check: - # Check if hash changed OR if .clang-tidy.hash was updated in this PR - # This is used in CI to determine if a full clang-tidy scan is needed - hash_changed = current_hash != stored_hash - - # Lazy import to avoid requiring dependencies that aren't needed for other modes - from helpers import changed_files # noqa: E402 - - hash_file_updated = ".clang-tidy.hash" in changed_files() - - # Exit 0 if full scan needed - sys.exit(0 if (hash_changed or hash_file_updated) else 1) - - elif args.verify: - # Verify that hash file is up to date with current configuration - # This is used in pre-commit and CI checks to ensure hash was updated - if current_hash != stored_hash: - print("ERROR: Clang-tidy configuration has changed but hash not updated!") - print(f"Expected: {current_hash}") - print(f"Found: {stored_hash}") - print("\nPlease run: script/clang_tidy_hash.py --update") - sys.exit(1) - print("Hash verification passed") - - elif args.update: - write_hash(current_hash) - print(f"Hash updated: {current_hash}") - - elif args.update_if_changed: - if current_hash != stored_hash: - write_hash(current_hash) - print(f"Clang-tidy hash updated: {current_hash}") - # Exit 0 so pre-commit can stage the file - sys.exit(0) - else: - print("Clang-tidy hash unchanged") - sys.exit(0) - - else: - print(f"Current hash: {current_hash}") - print(f"Stored hash: {stored_hash}") - print(f"Match: {current_hash == stored_hash}") - - -if __name__ == "__main__": - main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 94a78e8423f..4904883ca94 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -55,10 +55,10 @@ from functools import cache import json import os from pathlib import Path -import subprocess import sys from typing import Any +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, @@ -280,23 +280,22 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s @cache -def _is_clang_tidy_full_scan() -> bool: - """Check if clang-tidy configuration changed (requires full scan). +def _is_clang_tidy_full_scan(branch: str | None = None) -> bool: + """Check if a clang-tidy-relevant config file changed (requires full scan). + + A change to a file that affects clang-tidy globally can surface warnings in + source files the PR didn't touch, so the entire codebase must be re-scanned. Returns: - True if full scan is needed (hash changed), False otherwise. + True if full scan is needed, False otherwise. """ - try: - result = subprocess.run( - [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], - capture_output=True, - check=False, - ) - # Exit 0 means hash changed (full scan needed) - return result.returncode == 0 - except Exception: # noqa: BLE001 - # If hash check fails, run full scan to be safe - return True + for file in changed_files(branch): + if file in CLANG_TIDY_GLOBAL_FILES: + return True + # Root-level sdkconfig.defaults and per-target sdkconfig.defaults. + if "/" not in file and file.startswith(SDKCONFIG_DEFAULTS_PREFIX): + return True + return False def should_run_clang_tidy(branch: str | None = None) -> bool: @@ -307,13 +306,12 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: Clang-tidy will run when ANY of the following conditions are met: - 1. Clang-tidy configuration changed - - The hash of .clang-tidy configuration file has changed - - The hash includes the .clang-tidy file, clang-tidy version from requirements_dev.txt, - and relevant platformio.ini sections - - When configuration changes, a full scan is needed to ensure all code complies - with the new rules - - Detected by script/clang_tidy_hash.py --check returning exit code 0 + 1. A clang-tidy-relevant config file changed (full scan needed) + - Any file in CLANG_TIDY_GLOBAL_FILES (.clang-tidy, platformio.ini, + requirements_dev.txt, esphome/idf_component.yml) or a root-level + sdkconfig.defaults* file + - These affect clang-tidy results globally, so all code must be re-checked + to ensure it still complies 2. Any C++ source files changed - Any file with C++ extensions: .cpp, .h, .hpp, .cc, .cxx, .c, .tcc @@ -321,27 +319,14 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: - This ensures all C++ code is checked, including tests, examples, etc. - Examples: esphome/core/component.cpp, tests/custom/my_component.h - 3. The .clang-tidy.hash file itself changed - - This indicates the configuration has been updated and clang-tidy should run - - Ensures that PRs updating the clang-tidy configuration are properly validated - - If the hash check fails for any reason, clang-tidy runs as a safety measure to ensure - code quality is maintained. - Args: branch: Branch to compare against. If None, uses default. Returns: True if clang-tidy should run, False otherwise. """ - # First check if clang-tidy configuration changed (full scan needed) - if _is_clang_tidy_full_scan(): - return True - - # Check if .clang-tidy.hash file itself was changed - # This handles the case where the hash was properly updated in the PR - files = changed_files(branch) - if ".clang-tidy.hash" in files: + # First check if a clang-tidy-relevant config file changed (full scan needed) + if _is_clang_tidy_full_scan(branch): return True return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) @@ -1276,9 +1261,9 @@ def main() -> None: # Determine clang-tidy mode based on actual files that will be checked is_full_scan = False if run_clang_tidy: - # Full scan needed if: hash changed OR core files changed - # (is_core_change is forced True under --force-all) - is_full_scan = _is_clang_tidy_full_scan() or is_core_change + # Full scan needed if: a clang-tidy-relevant config file changed OR + # core files changed (is_core_change is forced True under --force-all) + is_full_scan = _is_clang_tidy_full_scan(args.branch) or is_core_change if is_full_scan: # Full scan checks all files - always use split mode for efficiency diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 194926a5df9..b5a9d8ebe9b 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -1,9 +1,7 @@ """Unit tests for script/clang_tidy_hash.py module.""" -import hashlib from pathlib import Path import sys -from unittest.mock import Mock, patch import pytest @@ -11,76 +9,45 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) import clang_tidy_hash # noqa: E402 +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES # noqa: E402 -@pytest.mark.parametrize( - ("file_content", "expected"), - [ - ( - "clang-tidy==18.1.5 # via -r requirements_dev.in\n", - "clang-tidy==18.1.5 # via -r requirements_dev.in", - ), - ( - "other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n", - "clang-tidy==17.0.0", - ), - ( - "# comment\nclang-tidy==16.0.0 # some comment\n", - "clang-tidy==16.0.0 # some comment", - ), - ("no-clang-tidy-here==1.0\n", "clang-tidy version not found"), - ], -) -def test_get_clang_tidy_version_from_requirements( - file_content: str, expected: str +def _populate(repo_root: Path) -> None: + """Create every clang-tidy global file plus a base sdkconfig.defaults.""" + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"contents of {name}\n") + (repo_root / "sdkconfig.defaults").write_text("CONFIG_BASE=y\n") + + +def test_calculate_clang_tidy_hash_is_deterministic(tmp_path: Path) -> None: + """Same inputs must produce the same hash.""" + _populate(tmp_path) + assert clang_tidy_hash.calculate_clang_tidy_hash( + repo_root=tmp_path + ) == clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + +@pytest.mark.parametrize("filename", CLANG_TIDY_GLOBAL_FILES) +def test_calculate_clang_tidy_hash_changes_with_each_global_file( + tmp_path: Path, filename: str ) -> None: - """Test extracting clang-tidy version from various file formats.""" - # Mock read_file_lines to return our test content - with patch("clang_tidy_hash.read_file_lines") as mock_read: - mock_read.return_value = file_content.splitlines(keepends=True) + """Editing any global file must change the hash.""" + _populate(tmp_path) + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - result = clang_tidy_hash.get_clang_tidy_version_from_requirements() + (tmp_path / filename).write_text("changed\n") + after = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - assert result == expected - - -def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash from all configuration sources including sdkconfig.defaults.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - sdkconfig_content = b"" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "sdkconfig.defaults").write_bytes(sdkconfig_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hasher.update(b"sdkconfig.defaults") - expected_hasher.update(sdkconfig_content) - expected_hash = expected_hasher.hexdigest() - - result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash + assert after != before def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( tmp_path: Path, ) -> None: """Per-target sdkconfig.defaults. files must be part of the hash.""" - (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") - (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") - (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") - (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") - + _populate(tmp_path) before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) # Adding a per-target file must change the hash. @@ -95,230 +62,14 @@ def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( assert after_edit != after_add -def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash without sdkconfig.defaults file.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files (without sdkconfig.defaults) - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation (no sdkconfig) - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hash = expected_hasher.hexdigest() - +def test_calculate_clang_tidy_hash_handles_missing_optional_files( + tmp_path: Path, +) -> None: + """Hash calculation must not fail when files are absent.""" + # Only .clang-tidy present; everything else missing. + (tmp_path / ".clang-tidy").write_text("Checks: '-*'\n") result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash - - -def test_read_stored_hash_exists(tmp_path: Path) -> None: - """Test reading hash when file exists.""" - stored_hash = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - hash_file.write_text(f"{stored_hash}\n") - - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result == stored_hash - - -def test_read_stored_hash_not_exists(tmp_path: Path) -> None: - """Test reading hash when file doesn't exist.""" - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result is None - - -def test_write_hash(tmp_path: Path) -> None: - """Test writing hash to file.""" - hash_value = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - - clang_tidy_hash.write_hash(hash_value, repo_root=tmp_path) - - assert hash_file.exists() - assert hash_file.read_text() == hash_value.strip() + "\n" - - -@pytest.mark.parametrize( - ("args", "current_hash", "stored_hash", "hash_file_in_changed", "expected_exit"), - [ - (["--check"], "abc123", "abc123", False, 1), # Hashes match, no scan needed - (["--check"], "abc123", "def456", False, 0), # Hashes differ, scan needed - (["--check"], "abc123", None, False, 0), # No stored hash, scan needed - ( - ["--check"], - "abc123", - "abc123", - True, - 0, - ), # Hash file updated in PR, scan needed - ], -) -def test_main_check_mode( - args: list[str], - current_hash: str, - stored_hash: str | None, - hash_file_in_changed: bool, - expected_exit: int, -) -> None: - """Test main function in check mode.""" - changed = [".clang-tidy.hash"] if hash_file_in_changed else [] - - # Create a mock module that can be imported - mock_helpers = Mock() - mock_helpers.changed_files = Mock(return_value=changed) - - with ( - patch("sys.argv", ["clang_tidy_hash.py"] + args), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch.dict("sys.modules", {"helpers": mock_helpers}), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == expected_exit - - -def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in update mode.""" - current_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - ): - clang_tidy_hash.main() - - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert f"Hash updated: {current_hash}" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hash changed, should update - ("abc123", None), # No stored hash, should update - ], -) -def test_main_update_if_changed_mode_update( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in update-if-changed mode when update is needed.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert "Clang-tidy hash updated" in captured.out - - -def test_main_update_if_changed_mode_no_update( - capsys: pytest.CaptureFixture[str], -) -> None: - """Test main function in update-if-changed mode when no update is needed.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_not_called() - captured = capsys.readouterr() - assert "Clang-tidy hash unchanged" in captured.out - - -def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in verify mode when verification passes.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - captured = capsys.readouterr() - assert "Hash verification passed" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hashes differ, verification fails - ("abc123", None), # No stored hash, verification fails - ], -) -def test_main_verify_mode_failure( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in verify mode when verification fails.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "ERROR: Clang-tidy configuration has changed" in captured.out - - -def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in default mode (no arguments).""" - current_hash = "abc123" - stored_hash = "def456" - - with ( - patch("sys.argv", ["clang_tidy_hash.py"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - - captured = capsys.readouterr() - assert f"Current hash: {current_hash}" in captured.out - assert f"Stored hash: {stored_hash}" in captured.out - assert "Match: False" in captured.out - - -def test_read_file_lines(tmp_path: Path) -> None: - """Test read_file_lines helper function.""" - test_file = tmp_path / "test.txt" - test_content = "line1\nline2\nline3\n" - test_file.write_text(test_content) - - result = clang_tidy_hash.read_file_lines(test_file) - - assert result == ["line1\n", "line2\n", "line3\n"] + assert len(result) == 64 # sha256 hexdigest length def test_read_file_bytes(tmp_path: Path) -> None: @@ -330,35 +81,3 @@ def test_read_file_bytes(tmp_path: Path) -> None: result = clang_tidy_hash.read_file_bytes(test_file) assert result == test_content - - -def test_write_file_content(tmp_path: Path) -> None: - """Test write_file_content helper function.""" - test_file = tmp_path / "test.txt" - test_content = "test content" - - clang_tidy_hash.write_file_content(test_file, test_content) - - assert test_file.read_text() == test_content - - -@pytest.mark.parametrize( - ("line", "expected"), - [ - ("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")), - ( - "clang-tidy==18.1.5 # comment", - ("clang-tidy", "clang-tidy==18.1.5 # comment"), - ), - ("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")), - ("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")), - ("# just a comment", None), - ("", None), - (" ", None), - ("invalid line without version", None), - ], -) -def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None: - """Test parsing individual requirement lines.""" - result = clang_tidy_hash.parse_requirement_line(line) - assert result == expected diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9defcacac7..f8f359ee22b 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -5,7 +5,7 @@ import importlib.util import json from pathlib import Path import sys -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch import pytest @@ -653,52 +653,38 @@ def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: @pytest.mark.parametrize( - ("check_returncode", "changed_files", "expected_result"), + ("changed_files", "expected_result"), [ - (0, [], True), # Hash changed - need full scan - (1, ["esphome/core.cpp"], True), # C++ file changed - (1, ["README.md"], False), # No C++ files changed - (1, [".clang-tidy.hash"], True), # Hash file itself changed - (1, ["platformio.ini", ".clang-tidy.hash"], True), # Config + hash changed + ([], False), # Nothing changed + (["esphome/core.cpp"], True), # C++ file changed + (["README.md"], False), # No C++ files changed + ([".clang-tidy"], True), # clang-tidy config changed - full scan + (["platformio.ini"], True), # build config changed - full scan + (["requirements_dev.txt"], True), # clang-tidy version source changed + (["sdkconfig.defaults"], True), # sdkconfig changed - full scan + (["sdkconfig.defaults.esp32c6"], True), # per-target sdkconfig changed + (["esphome/idf_component.yml"], True), # idf managed deps changed + (["platformio.ini", "README.md"], True), # config + non-C++ ], ) def test_should_run_clang_tidy( - check_returncode: int, changed_files: list[str], expected_result: bool, ) -> None: """Test should_run_clang_tidy function.""" - with ( - patch.object(determine_jobs, "changed_files", return_value=changed_files), - patch("subprocess.run") as mock_run, - ): - # Test with hash check returning specific code - mock_run.return_value = Mock(returncode=check_returncode) + with patch.object(determine_jobs, "changed_files", return_value=changed_files): result = determine_jobs.should_run_clang_tidy() assert result == expected_result -def test_should_run_clang_tidy_hash_check_exception() -> None: - """Test should_run_clang_tidy when hash check fails with exception.""" - # When hash check fails, clang-tidy should run as a safety measure - with ( - patch.object(determine_jobs, "changed_files", return_value=["README.md"]), - patch("subprocess.run", side_effect=Exception("Hash check failed")), - ): - result = determine_jobs.should_run_clang_tidy() - assert result is True # Fail safe - run clang-tidy - - def test_should_run_clang_tidy_with_branch() -> None: """Test should_run_clang_tidy with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=1) # Hash unchanged - determine_jobs.should_run_clang_tidy("release") - # Changed files is called twice now - once for hash check, once for .clang-tidy.hash check - assert mock_changed.call_count == 2 - mock_changed.assert_has_calls([call("release"), call("release")]) + determine_jobs.should_run_clang_tidy("release") + # changed_files is queried against the given branch by both the + # config-file full-scan check and the C++ extension check. + mock_changed.assert_called_with("release") @pytest.mark.parametrize( From c9095841ae74cc59093100907b19f29aa3bfab5b Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Thu, 18 Jun 2026 04:16:28 +0300 Subject: [PATCH 037/343] [ufm01] Add UFM-01 ultrasonic flow meter component (#16582) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/ufm01/__init__.py | 40 +++ esphome/components/ufm01/binary_sensor.py | 52 ++++ esphome/components/ufm01/sensor.py | 63 +++++ esphome/components/ufm01/ufm01.cpp | 234 ++++++++++++++++++ esphome/components/ufm01/ufm01.h | 57 +++++ tests/components/ufm01/common.yaml | 30 +++ tests/components/ufm01/test.esp32-idf.yaml | 4 + tests/components/ufm01/test.esp8266-ard.yaml | 4 + tests/components/ufm01/test.rp2040-ard.yaml | 4 + .../common/uart_2400_even/esp32-idf.yaml | 12 + .../common/uart_2400_even/esp8266-ard.yaml | 12 + .../common/uart_2400_even/rp2040-ard.yaml | 12 + 13 files changed, 525 insertions(+) create mode 100644 esphome/components/ufm01/__init__.py create mode 100644 esphome/components/ufm01/binary_sensor.py create mode 100644 esphome/components/ufm01/sensor.py create mode 100644 esphome/components/ufm01/ufm01.cpp create mode 100644 esphome/components/ufm01/ufm01.h create mode 100644 tests/components/ufm01/common.yaml create mode 100644 tests/components/ufm01/test.esp32-idf.yaml create mode 100644 tests/components/ufm01/test.esp8266-ard.yaml create mode 100644 tests/components/ufm01/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp32-idf.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 10128c64e52..3265627c030 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -561,6 +561,7 @@ esphome/components/uart/packet_transport/* @clydebarrow esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli +esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py new file mode 100644 index 00000000000..51cf3cfd91e --- /dev/null +++ b/esphome/components/ufm01/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@ljungqvist"] + +MULTI_CONF = True + +DEPENDENCIES = ["uart"] + +ufm01_ns = cg.esphome_ns.namespace("ufm01") +UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component) + +CONF_UFM01_ID = "ufm01_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UFM01Component), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ufm01", + require_tx=True, + require_rx=True, + baud_rate=2400, + parity="EVEN", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py new file mode 100644 index 00000000000..92ae585d962 --- /dev/null +++ b/esphome/components/ufm01/binary_sensor.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_UFC_CHIP_ERROR = "ufc_chip_error" +CONF_FLOW_DIRECTION_WRONG = "flow_direction_wrong" +CONF_EMPTY_TUBE = "empty_tube" +CONF_FLOW_RATE_OUT_OF_RANGE = "flow_rate_out_of_range" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_UFC_CHIP_ERROR): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, device_class=DEVICE_CLASS_PROBLEM + ), + cv.Optional(CONF_FLOW_DIRECTION_WRONG): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_EMPTY_TUBE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_FLOW_RATE_OUT_OF_RANGE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), +} + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): + sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) + cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) + + if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): + sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) + cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) + + if empty_tube_config := config.get(CONF_EMPTY_TUBE): + sens = await binary_sensor.new_binary_sensor(empty_tube_config) + cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) + + if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): + sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) + cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py new file mode 100644 index 00000000000..4dcd7ceebe7 --- /dev/null +++ b/esphome/components/ufm01/sensor.py @@ -0,0 +1,63 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FLOW, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + DEVICE_CLASS_WATER, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_LITRE, +) + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_ACCUMULATED_FLOW = "accumulated_flow" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_ACCUMULATED_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_LITRE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_WATER, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR, + accuracy_decimals=5, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:waves-arrow-right", + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:thermometer-water", + ), + } +) + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if CONF_ACCUMULATED_FLOW in config: + sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) + cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) + + if CONF_FLOW in config: + sens = await sensor.new_sensor(config[CONF_FLOW]) + cg.add(ufm01_component.set_flow_sensor(sens)) + + if CONF_TEMPERATURE in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) + cg.add(ufm01_component.set_temperature_sensor(sens)) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp new file mode 100644 index 00000000000..1380c342841 --- /dev/null +++ b/esphome/components/ufm01/ufm01.cpp @@ -0,0 +1,234 @@ +#include "ufm01.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ufm01 { + +static const char *const TAG = "ufm01"; + +static constexpr uint8_t COMMAND_ACK = 0xE5; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; + +static constexpr float L_PER_M3 = 1000.0f; +static constexpr float M3_PER_L = 1.0f / L_PER_M3; + +static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; + +// Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_CHECKSUM_INDEX = 30; +static constexpr size_t FRAME_STOP_INDEX = 31; +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; +static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; +static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; + +// Measurement decoding +static constexpr uint8_t FRAME_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t ACC_FLOW_M3_FLAG = 0x1A; +static constexpr uint8_t FRAME_FLOW_SIGN_INDEX = 20; +static constexpr uint8_t FLOW_NEGATIVE_SIGN = 0x80; + +// Status bytes (datasheet ST1 / ST2) +static constexpr uint8_t FRAME_ST1_INDEX = 28; +static constexpr uint8_t FRAME_ST2_INDEX = 29; +static constexpr uint8_t ST1_EMPTY_TUBE_MASK = 0x20; +static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; +static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; +static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; + +static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } + +static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { + if (data[index] == expected) + return true; + ESP_LOGW(TAG, "%s (byte %zu) - expected 0x%02X, but was 0x%02X", name, index, expected, data[index]); + return false; +} + +static bool validate_data(uint8_t data[FRAME_SIZE]) { + uint8_t sum = 0; + for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) + sum += data[i]; + return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && + check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && + check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && + check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") && + check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") && + check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") && + check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); +} + +static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * + (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); +} + +static float read_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * + (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + + to_float(data[16]) * 0.01f) * + M3_PER_L; +} + +static void log_hex(const uint8_t *data, size_t len) { + char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); +} + +static float read_temperature(uint8_t data[FRAME_SIZE]) { + // happens sometimes before getting a real reading + if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { + return NAN; + } + return to_float(data[27]) * 100.0f + to_float(data[26]) + to_float(data[25]) * 0.01f; +} + +static bool read_ufc_chip_error(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_UFC_ERROR_MASK; } + +static bool read_flow_direction_wrong(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_DIRECTION_WRONG_MASK; +} + +static bool read_empty_tube(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST1_INDEX] & ST1_EMPTY_TUBE_MASK; } + +static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->write_array(command); + this->flush(); + const uint32_t start = millis(); + while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { + if (this->available()) { + uint8_t byte; + if (this->read_byte(&byte)) { + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + } + delay(1); + } + return false; +} + +bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } + +bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } + +bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } + +float UFM01Component::get_setup_priority() const { return setup_priority::IO; } + +void UFM01Component::setup() { + ESP_LOGI(TAG, "Setting up UFM-01..."); + if (!this->set_active_mode_()) { + ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); + this->mark_failed(); + } +} + +void UFM01Component::dump_config() { + ESP_LOGCONFIG(TAG, "UFM-01:"); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Accumulated Flow", this->accumulated_flow_sensor_); + LOG_SENSOR(" ", "Flow", this->flow_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "UFC Chip Error", this->ufc_chip_error_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Direction Wrong", this->flow_direction_wrong_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); +#endif + this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); + if (this->is_failed()) { + ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); + } +} + +void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { + bool empty_tube = read_empty_tube(data); +#ifdef USE_BINARY_SENSOR + if (this->ufc_chip_error_binary_sensor_ != nullptr) + this->ufc_chip_error_binary_sensor_->publish_state(read_ufc_chip_error(data)); + if (this->flow_direction_wrong_binary_sensor_ != nullptr) + this->flow_direction_wrong_binary_sensor_->publish_state(read_flow_direction_wrong(data)); + if (this->empty_tube_binary_sensor_ != nullptr) + this->empty_tube_binary_sensor_->publish_state(empty_tube); + if (this->flow_rate_out_of_range_binary_sensor_ != nullptr) + this->flow_rate_out_of_range_binary_sensor_->publish_state(read_flow_rate_out_of_range(data)); +#endif + +#ifdef USE_SENSOR + // Total volume remains valid when the tube is dry; flow and temperature are not. + if (this->accumulated_flow_sensor_ != nullptr) + this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); + + if (empty_tube) { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + } else { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(read_flow(data)); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(read_temperature(data)); + } +#endif +} + +void UFM01Component::loop() { + // Drain the UART buffer each loop, reading one byte at a time into the frame + while (this->available()) { + if (!this->read_byte(&this->data_[this->read_index_])) { + ESP_LOGW(TAG, "unable to read byte"); + this->read_index_ = 0; + continue; + } + if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || + (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { + ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + this->read_index_ = 0; + continue; + } + if (++this->read_index_ < static_cast(FRAME_SIZE)) + continue; + + // Full frame received + if (validate_data(this->data_)) { + this->on_data_(this->data_); + this->read_index_ = 0; + continue; + } + + // Invalid frame: try to resync on the next start marker within the buffer + log_hex(this->data_, sizeof(this->data_)); + ESP_LOGE(TAG, "unable to read data"); + for (int32_t i = 2; + i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { + if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { + for (int32_t j = i; j < static_cast(FRAME_SIZE); ++j) + this->data_[j - i] = this->data_[j]; + this->read_index_ = static_cast(FRAME_SIZE) - i; + } + } + if (this->read_index_ == static_cast(FRAME_SIZE)) + this->read_index_ = 0; + } +} + +} // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h new file mode 100644 index 00000000000..e759de91690 --- /dev/null +++ b/esphome/components/ufm01/ufm01.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +#include + +// component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf + +namespace esphome::ufm01 { + +static constexpr size_t FRAME_SIZE = 32; + +class UFM01Component : public uart::UARTDevice, public Component { +#ifdef USE_SENSOR + SUB_SENSOR(accumulated_flow) + SUB_SENSOR(flow) + SUB_SENSOR(temperature) +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(ufc_chip_error) + SUB_BINARY_SENSOR(flow_direction_wrong) + SUB_BINARY_SENSOR(empty_tube) + SUB_BINARY_SENSOR(flow_rate_out_of_range) +#endif + + public: + void setup() override; + + void dump_config() override; + + void loop() override; + + float get_setup_priority() const override; + + protected: + bool clear_accumulated_flow_(); + bool set_active_mode_(); + bool reset_device_(); + + private: + bool send_command_(const std::array &command); + + int32_t read_index_ = 0; + uint8_t data_[FRAME_SIZE]; + void on_data_(uint8_t data[FRAME_SIZE]); +}; + +} // namespace esphome::ufm01 diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml new file mode 100644 index 00000000000..c818dc29651 --- /dev/null +++ b/tests/components/ufm01/common.yaml @@ -0,0 +1,30 @@ +ufm01: + id: ufm01_component + uart_id: uart_bus + +sensor: + - platform: ufm01 + accumulated_flow: + id: accumulated_flow + name: "Accumulated flow" + flow: + id: flow + name: "Flow" + temperature: + id: temperature + name: "Temperature" + +binary_sensor: + - platform: ufm01 + ufc_chip_error: + id: ufc_chip_error + name: "UFC chip error" + flow_direction_wrong: + id: flow_direction_wrong + name: "Flow direction wrong" + empty_tube: + id: empty_tube + name: "Empty tube" + flow_rate_out_of_range: + id: flow_rate_out_of_range + name: "Flow rate out of range" diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml new file mode 100644 index 00000000000..34041cc2237 --- /dev/null +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml new file mode 100644 index 00000000000..195f4b41b59 --- /dev/null +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml new file mode 100644 index 00000000000..13b3284fe30 --- /dev/null +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml new file mode 100644 index 00000000000..92a65c463e7 --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 IDF tests - 2400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml new file mode 100644 index 00000000000..00333867dbe --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP8266 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml new file mode 100644 index 00000000000..c915e7846dd --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for RP2040 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN From e3f164fff20edb2e3aa7e4b9a3a4330d4c41fbec Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 18 Jun 2026 03:17:07 +0200 Subject: [PATCH 038/343] [nrf52] add support for native builds (#16898) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/build_gen/espidf.py | 12 +-- esphome/components/nrf52/__init__.py | 98 +++++++++++++++++- esphome/components/nrf52/framework.py | 114 ++++++++++++++++++--- esphome/components/nrf52/requirements.txt | 3 + esphome/framework_helpers.py | 19 ++++ tests/unit_tests/build_gen/test_espidf.py | 48 +++++++++ tests/unit_tests/test_framework_helpers.py | 83 +++++++++++++++ tests/unit_tests/test_nrf52_framework.py | 33 +++--- 8 files changed, 369 insertions(+), 41 deletions(-) create mode 100644 esphome/components/nrf52/requirements.txt diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9e11d785c06..dec6ea04deb 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,6 +6,7 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE +from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -84,12 +85,7 @@ def get_project_cmakelists(minimal: bool = False) -> str: # esphome__micro-mp3) rather than just src/. Required so suppressions # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # third-party components we don't author. - project_compile_opts = [ - flag - for flag in sorted(CORE.build_flags) - if flag.startswith("-D") - or (flag.startswith("-W") and not flag.startswith("-Wl,")) - ] + project_compile_opts = get_project_compile_flags() extra_compile_options = "\n".join( f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' for flag in project_compile_opts @@ -188,8 +184,8 @@ def get_component_cmakelists() -> str: # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. - link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" + link_opts = get_project_link_flags() + link_opts_str = "\n ".join(link_opts) if link_opts else "" return f"""\ # Auto-generated by ESPHome diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 56367d0b267..d87318b03db 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -52,6 +52,11 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_link_flags, + run_command_ok, +) from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -63,7 +68,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install +from .framework import check_and_install, get_build_env, get_build_paths # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -99,9 +104,6 @@ FAKE_BOARD_MANIFEST = """ def set_core_data(config: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -112,6 +114,12 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +def _resolve_toolchain(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return config + + def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" @@ -147,6 +155,12 @@ BOOTLOADERS = [ ] +def _validate_toolchain(value) -> Toolchain: + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) + ) + + def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() @@ -233,9 +247,11 @@ CONFIG_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _resolve_toolchain, set_framework, ) @@ -565,6 +581,47 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +def _generate_cmake_lists() -> None: + compile_flags = get_project_compile_flags() + link_flags = get_project_link_flags() + + lines = [ + "cmake_minimum_required(VERSION 3.20.0)", + "", + 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', + "", + "find_package(Zephyr REQUIRED)", + "", + f"project({CORE.name})", + "", + 'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")', + "", + "target_sources(app PRIVATE ${APP_SOURCES})", + 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', + ] + + if compile_flags: + lines += [ + "", + "target_compile_options(app PRIVATE", + *[f' "{flag}"' for flag in compile_flags], + ")", + ] + + if link_flags: + lines += [ + "", + "zephyr_ld_options(", + *[f' "{flag}"' for flag in link_flags], + ")", + ] + + write_file_if_changed( + CORE.relative_build_path("zephyr", "CMakeLists.txt"), + "\n".join(lines) + "\n", + ) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -574,4 +631,35 @@ def run_compile(args, config: ConfigType) -> bool: "Supported toolchains are 'platformio' and 'sdk-nrf'." ) check_and_install() - raise EsphomeError("Native build for nRF52 is not implemented yet") + + paths = get_build_paths() + env = get_build_env() + + _generate_cmake_lists() + + board = zephyr_data()[KEY_BOARD] + build_dir = CORE.relative_pioenvs_path(CORE.name) + source_dir = CORE.relative_build_path("zephyr") + + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + ] + + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 native build failed") + + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 607ad0c7edc..a35ba3ef85d 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -18,7 +18,7 @@ from esphome.framework_helpers import ( _LOGGER = logging.getLogger(__name__) -_WEST_VERSION = "1.5.0" +_REQUIREMENTS = Path(__file__).parent / "requirements.txt" _TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( @@ -28,6 +28,15 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( ) ) +# Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and +# host tools (dtc etc.) required by the Zephyr cmake build system. +SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_MINIMAL_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/zephyr-sdk-{VERSION}_{sysname}-{machine}_minimal.{extension}", + ) +) + def _get_tools_path() -> Path: return CORE.data_dir / "sdk-nrf" @@ -38,11 +47,11 @@ def _get_python_env_path(version: str) -> Path: def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / f"{version}" + return _get_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / f"{version}" + return _get_tools_path() / "toolchains" / version # onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. @@ -95,29 +104,68 @@ def _get_toolchain_platform_info() -> tuple[str, str, str]: return sysname, machine, extension -def check_and_install() -> None: +def _get_version_str() -> str: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + return f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + + +def get_build_paths() -> dict: + version = _get_version_str() + return { + "python_executable": get_python_env_executable_path( + _get_python_env_path(version), "python" + ), + "framework_path": _get_framework_path(version), + } + + +def get_build_env() -> dict: + version = _get_version_str() + venv_bin_dir = get_python_env_executable_path( + _get_python_env_path(version), "python" + ).parent + env = os.environ.copy() + env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") + env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + return env + + +def check_and_install() -> None: + version = _get_version_str() python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" - install_venv = not sentinel.exists() + install_venv = ( + not sentinel.exists() + or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") - create_venv(python_env_path, msg=f"{version}") + create_venv(python_env_path, msg=version) _install_sitecustomize(python_env_path) - _LOGGER.info("Installing west %s ...", _WEST_VERSION) - cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + _LOGGER.info("Installing requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + ] if not run_command_ok(cmd): - raise EsphomeError(f"Install west for {version} Python environment failure") + raise EsphomeError( + f"Install requirements for {version} Python environment failure" + ) sentinel.touch() framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" - if install_venv or not sentinel.exists(): + zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + if not sentinel.exists() or not zephyr_reqs.exists(): rmdir(framework_path, msg=f"Clean up {version} framework environment") _LOGGER.info("Initializing nRF Connect SDK %s ...", version) cmd = [ @@ -128,7 +176,7 @@ def check_and_install() -> None: "-m", "https://github.com/nrfconnect/sdk-nrf", "--mr", - f"{version}", + version, str(framework_path), ] if not run_command_ok(cmd): @@ -146,17 +194,47 @@ def check_and_install() -> None: raise EsphomeError(f"Can't update nRF Connect SDK {version}") sentinel.touch() + zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" + if ( + install_venv + or not zephyr_sentinel.exists() + or zephyr_reqs.stat().st_mtime > zephyr_sentinel.stat().st_mtime + ): + _LOGGER.info("Installing Zephyr requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(zephyr_reqs), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Install Zephyr requirements for {version} failure") + zephyr_sentinel.touch() + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): rmdir( toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" ) + sysname, machine, extension = _get_toolchain_platform_info() + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + download_from_mirrors( + SDK_NG_MINIMAL_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) - - sysname, machine, extension = _get_toolchain_platform_info() - download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { @@ -167,5 +245,9 @@ def check_and_install() -> None: }, tmp.file, ) - archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + archive_extract_all( + tmp.file, + toolchains_dir / "arm-zephyr-eabi", + progress_header="Extracting", + ) sentinel.touch() diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt new file mode 100644 index 00000000000..250d3a29cfe --- /dev/null +++ b/esphome/components/nrf52/requirements.txt @@ -0,0 +1,3 @@ +west==1.5.0 +ninja==1.13.0 +cmake==4.3.2 diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 276dfbbf1c3..6bf389240b0 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -20,6 +20,25 @@ PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +def get_project_link_flags() -> list[str]: + """Return the sorted -Wl, linker flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,")) + + +def get_project_compile_flags() -> list[str]: + """Return the sorted -D and -W (non-linker) flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a5c2719f426..0f4444f719b 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_component_cmakelists_no_link_flags() -> None: + """With no -Wl, flags the target_link_options block is emitted with an empty body.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content + + +def test_get_component_cmakelists_single_link_flag() -> None: + """A single -Wl, flag appears indented inside target_link_options.""" + CORE.build_flags = {"-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)" + in content + ) + + +def test_get_component_cmakelists_multiple_link_flags_sorted() -> None: + """Multiple -Wl, flags are sorted and joined with the four-space indent.""" + CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + expected = ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n" + " -Wl,--gc-sections\n" + " -Wl,-Map=out.map\n" + " -Wl,-z,noexecstack\n" + ")" + ) + assert expected in content + + +def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None: + """-D and -W (non-linker) flags must not appear in target_link_options.""" + CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "-DFOO" not in content.split("target_link_options")[1] + assert "-Wall" not in content.split("target_link_options")[1] + assert "-Wl,--gc-sections" in content + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index a8533608c01..f6e783b5e82 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -25,6 +25,8 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + get_project_compile_flags, + get_project_link_flags, get_python_env_executable_path, get_system_python_path, rmdir, @@ -952,3 +954,84 @@ class TestSevenZipExtractAll: out.mkdir() archive_extract_all(archive, out) assert (out / "hello.txt").exists() + + +# --------------------------------------------------------------------------- +# get_project_compile_flags / get_project_link_flags +# --------------------------------------------------------------------------- + + +def _make_core(flags: set[str]): + core = MagicMock() + core.build_flags = flags + return core + + +class TestGetProjectCompileFlags: + def test_returns_define_flags(self) -> None: + with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})): + assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"] + + def test_returns_warning_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wno-error", "-Wall"}), + ): + assert get_project_compile_flags() == ["-Wall", "-Wno-error"] + + def test_excludes_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_excludes_other_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-O2", "-std=gnu++20", "-DFOO"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_compile_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}), + ): + result = get_project_compile_flags() + assert result == sorted(result) + + +class TestGetProjectLinkFlags: + def test_returns_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_link_flags() == [ + "-Wl,--gc-sections", + "-Wl,-Map=output.map", + ] + + def test_excludes_compile_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}), + ): + assert get_project_link_flags() == ["-Wl,--gc-sections"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_link_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}), + ): + result = get_project_link_flags() + assert result == sorted(result) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 9652ad08eb9..04c712f0b73 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -58,6 +58,9 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) + zephyr_scripts = framework / "zephyr" / "scripts" + zephyr_scripts.mkdir(parents=True, exist_ok=True) + (zephyr_scripts / "requirements.txt").touch() return SimpleNamespace( python_env=python_env, framework=framework, @@ -102,6 +105,7 @@ class TestCheckAndInstall: ) -> None: """All three sentinels present → nothing downloaded or compiled.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -121,11 +125,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install west, west init, west update - assert mock_nrf52_ops.run_command_ok.call_count == 3 - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # pip install requirements, west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 4 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists() assert (nrf52_dirs.framework / ".ready").exists() assert (nrf52_dirs.toolchain / ".ready").exists() @@ -140,9 +146,10 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init + west update only (no pip install) - assert mock_nrf52_ops.run_command_ok.call_count == 2 - mock_nrf52_ops.download_from_mirrors.assert_called_once() + # west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 3 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 def test_toolchain_only_missing( self, @@ -151,24 +158,26 @@ class TestCheckAndInstall: ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() check_and_install() mock_nrf52_ops.create_venv.assert_not_called() mock_nrf52_ops.run_command_ok.assert_not_called() - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 - def test_west_install_failure_raises( + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, mock_nrf52_ops: SimpleNamespace, ) -> None: - """Failing pip install west raises EsphomeError.""" + """Failing pip install -r requirements.txt raises EsphomeError.""" mock_nrf52_ops.run_command_ok.return_value = False - with pytest.raises(EsphomeError, match="Install west"): + with pytest.raises(EsphomeError, match="Install requirements"): check_and_install() def test_framework_init_failure_raises( From c214a8ce799cfa483eaecbf8cad4dc3d3ceaaf44 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:21:00 +1200 Subject: [PATCH 039/343] [core] Add generic component alias infrastructure (#16826) --- esphome/config.py | 102 +++++ esphome/loader.py | 305 +++++++++++++++ tests/unit_tests/test_loader.py | 663 +++++++++++++++++++++++++++++++- 3 files changed, 1068 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 91e6df8bad5..33e687137f2 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -137,6 +137,96 @@ def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool: return path[: len(other)] == other +# CORE.data key for the per-alias "already warned this run" dedupe set. +# Cleared between runs because CORE.data is reset; one warning per alias +# per `esphome config|compile|run` invocation is the desired UX. +_ALIAS_WARNED_KEY = "_component_aliases_warned" + + +def _resolve_component_aliases(config: dict[str, Any]) -> None: + """Rewrite legacy top-level keys to their canonical names, in place. + + Looks up each top-level key against the component-alias map built by + :mod:`esphome.loader` (see ``ComponentManifest.aliases``); when a + matching alias is found, the key is moved to its canonical name and a + one-shot deprecation warning is logged (per alias, per run — deduped + via ``CORE.data``). + + Ambiguous configurations raise ``cv.Invalid`` rather than silently + keeping one entry — that would hide a real misconfiguration. Two cases + are rejected: the canonical key together with one of its deprecated + aliases, and two or more different aliases of the same canonical + component. + + The rest of the validator chain (dependency resolution, schema + validation, codegen) sees only canonical names, so component + `DEPENDENCIES = [""]` works regardless of which spelling + the user typed. + """ + alias_meta_map = loader.get_alias_metadata() + if not alias_meta_map: + return + + # Group every legacy alias key present in the config by the canonical + # component it resolves to, preserving config order within each group. + legacy_by_canonical: dict[str, list[str]] = {} + for key in config: + meta = alias_meta_map.get(key) + if meta is not None: + legacy_by_canonical.setdefault(meta.canonical, []).append(key) + + if not legacy_by_canonical: + return + + # Reject ambiguous configurations up front — checking before rewriting + # means a conflict is caught regardless of key order. + for canonical, legacies in legacy_by_canonical.items(): + if canonical in config: + # The canonical key and (at least) one deprecated alias are both + # present. + raise vol.Invalid( + f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') " + f"and '{canonical}:' are present in the configuration. Remove " + f"the deprecated '{legacies[0]}:' key.", + path=[legacies[0]], + ) + if len(legacies) > 1: + # Several different deprecated aliases of the same component. + listed = ", ".join(f"'{alias}:'" for alias in legacies) + raise vol.Invalid( + f"Multiple deprecated aliases of '{canonical}:' are present " + f"({listed}). Use only '{canonical}:'.", + path=[legacies[0]], + ) + + warned: set[str] = CORE.data.setdefault(_ALIAS_WARNED_KEY, set()) + + # Rebuild in place so each canonical key keeps the legacy key's original + # position — top-level key order matters for some downstream passes + # (e.g. auto-load ordering). A plain `config[canonical] = config.pop(...)` + # would instead move the renamed key to the end. + rewritten: dict[str, Any] = {} + for key, value in config.items(): + meta = alias_meta_map.get(key) + if meta is None: + rewritten[key] = value + continue + rewritten[meta.canonical] = value + if key not in warned: + warned.add(key) + removal = ( + f" Removed in {meta.removal_version}." if meta.removal_version else "" + ) + _LOGGER.warning( + "The '%s:' top-level key is deprecated; rename it to '%s:'.%s", + key, + meta.canonical, + removal, + ) + config.clear() + config.update(rewritten) + + @functools.total_ordering class _ValidationStepTask: def __init__(self, priority: float, id_number: int, step: ConfigValidationStep): @@ -1048,6 +1138,18 @@ def validate_config( substitutions = config.pop(CONF_SUBSTITUTIONS, None) CORE.raw_config = config + # 1.15. Resolve component aliases so legacy top-level keys + # (`rp2040:`, …) route to their canonical component before any + # downstream pass touches the config. Logs a deprecation warning + # per alias; mutates `config` in place. Errors here surface as + # plain config errors and abort further validation. + try: + _resolve_component_aliases(config) + except vol.Invalid as err: + result.update(config) + result.add_error(err) + return result + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/esphome/loader.py b/esphome/loader.py index 8823d82fc1a..a9287abf866 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -101,6 +101,27 @@ class ComponentManifest: def codeowners(self) -> list[str]: return getattr(self.module, "CODEOWNERS", []) + @property + def aliases(self) -> list[str]: + """Legacy names that should transparently route to this component. + + See the :func:`_build_alias_map` documentation for how aliases are + discovered (AST scan, no execution) and registered both for the YAML + loader (top-level key rename in :mod:`esphome.config`) and for + Python imports (``sys.meta_path`` finder, below). + """ + return getattr(self.module, "ALIASES", []) + + @property + def alias_removal_version(self) -> str | None: + """Optional ESPHome version when the alias warning becomes a hard error. + + Surfaced in the deprecation warning emitted by the YAML pre-pass so + users know how long they have to migrate. ``None`` means the warning + does not mention a specific version. + """ + return getattr(self.module, "ALIAS_REMOVAL_VERSION", None) + @property def instance_type(self) -> "MockObjClass | None": return getattr(self.module, "INSTANCE_TYPE", None) @@ -216,6 +237,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: _COMPONENT_CACHE[domain] = manif return manif + # If `domain` is the legacy name of a renamed component, redirect to the + # canonical module so the rest of the loader (and every caller of + # `get_component(legacy)`) transparently sees the new component. + alias_map = _get_alias_map() + if domain in alias_map: + canonical = alias_map[domain] + manif = _lookup_module(canonical, exception) + if manif is not None: + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -261,3 +293,276 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non code should never call this. """ _COMPONENT_CACHE[domain] = manifest + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two +# integrations are then wired up automatically: +# +# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) +# intercepts ``esphome.components.``/``....`` +# imports and resolves them against the canonical component so external +# custom components that still import from the old path keep working. +# +# 2. **YAML loader** — ``_lookup_module`` consults the alias map so +# ``get_component("legacy")`` returns the canonical manifest. The +# ``esphome.config`` pre-pass uses the same map to rewrite legacy +# top-level keys in the user's config (with a deprecation warning) so +# dependency checks, schema validation and codegen all see only the +# canonical name. +# +# Both lookups are populated by ``_build_alias_map``, which **AST-parses** +# every component's ``__init__.py`` rather than importing it. That keeps the +# cost low: scanning ~400 components on disk takes ~5 ms instead of the +# multi-second cost of executing every component's import side-effects. + + +_ALIAS_MAP_CACHE: dict[str, str] | None = None +_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None + + +@dataclass(frozen=True) +class AliasMeta: + """Metadata for a single deprecated alias entry. + + Used by the YAML pre-pass in :mod:`esphome.config` to produce a + deprecation warning citing the canonical name and (optionally) the + removal version declared by the canonical component. + """ + + canonical: str + removal_version: str | None + + +def _ensure_alias_caches() -> None: + """Populate both alias caches from a single directory scan. + + ``_build_alias_map`` returns both maps together, so building them in one + shot avoids scanning every component's ``__init__.py`` twice when a run + needs both the canonical map (loader) and the metadata map (config + pre-pass). + """ + global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE + if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: + _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() + + +def _get_alias_map() -> dict[str, str]: + """Return the legacy-name → canonical-name map, building it lazily.""" + _ensure_alias_caches() + return _ALIAS_MAP_CACHE + + +def get_alias_metadata() -> dict[str, AliasMeta]: + """Return the legacy-name → :class:`AliasMeta` map (cached). + + Used by the YAML pre-pass to format a per-alias deprecation warning. + """ + _ensure_alias_caches() + return _ALIAS_META_CACHE + + +def _build_alias_map() -> tuple[dict[str, str], dict[str, AliasMeta]]: + """Scan every core component dir for ``ALIASES`` declarations. + + Uses :mod:`ast` to read each component's ``__init__.py`` without + executing it — component import side-effects (logger setup, + namespace registration, etc.) shouldn't run just because we're + enumerating aliases. + + Raises if the same alias is claimed by two canonical components, since + silently picking one would cause non-deterministic routing depending on + directory-iteration order. Also raises if an alias shadows an existing + component package: that would hijack a live component domain and, in the + self-alias case (alias == canonical), send ``_lookup_module`` into + infinite recursion redirecting a domain to itself. + """ + import ast + + alias_to_canonical: dict[str, str] = {} + alias_to_meta: dict[str, AliasMeta] = {} + + if not CORE_COMPONENTS_PATH.is_dir(): + return alias_to_canonical, alias_to_meta + + for child in sorted(CORE_COMPONENTS_PATH.iterdir()): + if not child.is_dir(): + continue + init = child / "__init__.py" + if not init.is_file(): + continue + aliases, removal_version = _read_aliases(init, ast) + if not aliases: + continue + canonical = child.name + for alias in aliases: + if (CORE_COMPONENTS_PATH / alias / "__init__.py").is_file(): + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' (declared by '{canonical}') " + "shadows an existing component package of the same name. " + "An alias may only name a component that no longer exists." + ) + if alias in alias_to_canonical: + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' is declared by both " + f"'{alias_to_canonical[alias]}' and '{canonical}'. " + "Each alias must map to exactly one canonical component." + ) + alias_to_canonical[alias] = canonical + alias_to_meta[alias] = AliasMeta( + canonical=canonical, removal_version=removal_version + ) + return alias_to_canonical, alias_to_meta + + +def _read_aliases( + init_path: Path, ast_module: ModuleType +) -> tuple[list[str], str | None]: + """Extract ``ALIASES`` and ``ALIAS_REMOVAL_VERSION`` from a component + ``__init__.py`` via AST parsing. + + Only handles the simple ``NAME = [str_literal, ...]`` / ``NAME = "..."`` + forms — anything more dynamic (function call, conditional, etc.) is + silently ignored. Components should keep their alias declarations + static so this scanner can see them. + """ + try: + source = init_path.read_text(encoding="utf-8") + except OSError as err: + _LOGGER.warning( + "Could not read %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + # Cheap substring pre-filter: almost no component declares ALIASES, and + # parsing every component __init__.py with ast is comparatively expensive. + # Skip the parse entirely unless the token appears in the file at all. + if "ALIASES" not in source: + return [], None + + try: + tree = ast_module.parse(source) + except SyntaxError as err: + _LOGGER.warning( + "Could not parse %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + aliases: list[str] = [] + removal_version: str | None = None + + for node in tree.body: + if not isinstance(node, ast_module.Assign): + continue + for target in node.targets: + if not isinstance(target, ast_module.Name): + continue + if target.id == "ALIASES" and isinstance(node.value, ast_module.List): + aliases.extend( + elt.value + for elt in node.value.elts + if isinstance(elt, ast_module.Constant) + and isinstance(elt.value, str) + ) + elif ( + target.id == "ALIAS_REMOVAL_VERSION" + and isinstance(node.value, ast_module.Constant) + and isinstance(node.value.value, str) + ): + removal_version = node.value.value + return aliases, removal_version + + +class _AliasFinder(importlib.abc.MetaPathFinder): + """``sys.meta_path`` finder that resolves legacy-component imports. + + Routes ``esphome.components.[.]`` to the canonical + component's module/submodule of the same name, so external code that + still imports ``from esphome.components.rp2040 import boards`` keeps + working without the canonical component having to maintain a shim + package on disk. + + The finder caches the resolved module in ``sys.modules`` under the + legacy name on first lookup, so subsequent imports hit the cache and + skip this finder entirely. + """ + + _PREFIX = "esphome.components." + + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + if not fullname.startswith(self._PREFIX): + return None + # Anything matching the ``esphome.components.`` prefix splits into at + # least three parts, so ``parts[2]`` (the domain) always exists. + parts = fullname.split(".") + domain = parts[2] + alias_map = _get_alias_map() + if domain not in alias_map: + return None + + parts[2] = alias_map[domain] + canonical_fullname = ".".join(parts) + try: + canonical_module = importlib.import_module(canonical_fullname) + except ModuleNotFoundError as err: + # Only treat a missing *canonical target* as "no alias to + # resolve" (let the normal import machinery report it). If some + # other module is missing, the canonical exists but failed to + # import one of its own dependencies — surface that real error + # rather than masking it as an unresolved alias. + if err.name == canonical_fullname: + return None + raise + # Do NOT pre-populate ``sys.modules[fullname]`` here. Python's + # ``_find_spec`` (in importlib._bootstrap) has an optimization that + # detects ``name in sys.modules`` after a finder returns and prefers + # ``sys.modules[name].__spec__`` over the finder's spec — for an + # alias, that's the canonical module's own SourceFileLoader spec, + # which Python then *re-loads*, defeating the aliasing. Letting + # ``_load_unlocked`` populate sys.modules itself (via our + # ``_AliasLoader.create_module``) sidesteps that branch. + return importlib.util.spec_from_loader(fullname, _AliasLoader(canonical_module)) + + +class _AliasLoader(importlib.abc.Loader): + """No-op loader that returns the already-resolved canonical module. + + :class:`_AliasFinder` populates ``sys.modules`` itself; this loader + just satisfies the :mod:`importlib` protocol so Python doesn't try to + re-execute the module. + """ + + def __init__(self, module: ModuleType) -> None: + self._module = module + + def create_module(self, spec): # noqa: ARG002 + return self._module + + def exec_module(self, module): # noqa: ARG002 + # Nothing to execute — the canonical module is already initialized. + return None + + +# Register once at module load. Idempotent: re-installing the finder on +# repeated imports (e.g. by tests that reload `esphome.loader`) is a no-op +# because we check for an existing instance first. +def _install_alias_finder() -> None: + for entry in sys.meta_path: + if isinstance(entry, _AliasFinder): + return + sys.meta_path.append(_AliasFinder()) + + +_install_alias_finder() diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 3fb0eca4a06..42e5203a737 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,8 +1,28 @@ """Unit tests for esphome.loader module.""" -from unittest.mock import MagicMock, patch +import ast +import logging +from pathlib import Path +import sys +import textwrap +from types import ModuleType +from unittest.mock import MagicMock, Mock, patch -from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +import pytest +import voluptuous as vol + +from esphome import config as esphome_config, config_validation as cv +from esphome.core import CORE +import esphome.loader as loader_mod +from esphome.loader import ( + AliasMeta, + ComponentManifest, + _AliasFinder, + _build_alias_map, + _read_aliases, + _replace_component_manifest, + get_component, +) from tests.testing_helpers import ComponentManifestOverride # --------------------------------------------------------------------------- @@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub names = [r.resource for r in manifest.resources] assert names == ["wake/wake_freertos.cpp"] + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# These tests pin down the substrate behind `ALIASES = [...]` on component +# `__init__.py` files: the AST scanner, the resulting global alias map, the +# Python-import `sys.meta_path` finder, the `get_component` integration, and +# the YAML pre-pass that rewrites legacy top-level keys. +# +# The framework is component-agnostic, so the integration tests inject a +# synthetic alias map (pointing a fake legacy name at the real `esp32` +# component) rather than depending on any specific renamed component. + +# A legacy name that is NOT a real component, used as a synthetic alias. +_FAKE_ALIAS = "esp32_legacy_alias" + + +def _write_component(root: Path, name: str, body: str) -> None: + """Write a fake component package at ``root//__init__.py``.""" + pkg = root / name + pkg.mkdir() + (pkg / "__init__.py").write_text(body) + + +def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None: + """AST scan should pick up ``ALIASES = ["legacy"]`` without executing.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['legacy_name']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == ["legacy_name"] + assert removal is None + + +def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: + """``ALIAS_REMOVAL_VERSION`` should be paired with the alias list.""" + init = tmp_path / "__init__.py" + init.write_text( + textwrap.dedent("""\ + ALIASES = ['old'] + ALIAS_REMOVAL_VERSION = "2027.6.0" + """) + ) + aliases, removal = _read_aliases(init, ast) + assert aliases == ["old"] + assert removal == "2027.6.0" + + +def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: + """A call-expression / non-literal ALIASES shouldn't surface — the + scanner deliberately ignores anything non-static to keep behavior + predictable (and avoid executing component code).""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n") + aliases, _ = _read_aliases(init, ast) + assert aliases == [] + + +def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None: + init = tmp_path / "__init__.py" + init.write_text("CODEOWNERS = ['@me']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == [] + assert removal is None + + +def test_read_aliases_handles_syntax_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken __init__.py shouldn't crash the alias scanner — it'll + surface as an ImportError elsewhere, but the scanner logs a warning and + yields nothing so other components keep working. The substring pre-filter + only skips files with no ``ALIASES`` token, so this file (which has one) + still reaches the parse.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['x']\ndef broken( :\n") + assert _read_aliases(init, ast) == ([], None) + assert "Could not parse" in caplog.text + + +def test_read_aliases_handles_read_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unreadable __init__.py logs a warning and yields nothing rather + than aborting the whole component scan.""" + missing = tmp_path / "nope" / "__init__.py" + assert _read_aliases(missing, ast) == ([], None) + assert "Could not read" in caplog.text + + +def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: + """End-to-end map build over a fake components dir.""" + _write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n") + _write_component(tmp_path, "other", "") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, meta_map = _build_alias_map() + + assert alias_map == {"oldcomp": "newcomp"} + assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)} + + +def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None: + _write_component( + tmp_path, + "newcomp", + "ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n", + ) + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + _, meta_map = _build_alias_map() + + assert meta_map["oldcomp"].removal_version == "2028.1.0" + + +def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None: + """If two canonical components both claim the same legacy alias, + routing becomes ambiguous — the build must refuse to start so the + conflict surfaces immediately at import time, not later as a + 'mysterious wrong component' bug.""" + _write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n") + _write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shared"), + ): + _build_alias_map() + + +def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: + """If the components directory doesn't exist (unlikely in production, + but possible in some test contexts), we want an empty map rather than + a crash — the rest of the loader can still function.""" + fake = tmp_path / "does-not-exist" + with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): + alias_map, meta_map = _build_alias_map() + assert alias_map == {} + assert meta_map == {} + + +def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: + """An alias that names an existing component package is refused: it would + hijack a live domain, and a self-alias (alias == canonical) would send + ``_lookup_module`` into infinite recursion.""" + # `newcomp` declares itself as an alias — its own package already exists. + _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shadows an existing component"), + ): + _build_alias_map() + + +# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- + + +def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: + """Force the loader's alias map (used by the finder and get_component). + + Patches the lazily-built caches so both ``_get_alias_map`` and the + installed meta-path finder resolve against ``mapping`` regardless of + what the real on-disk scan would produce. + """ + monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) + + +def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """``get_component()`` should return the canonical manifest — every + caller of the loader (dep checker, schema validator, codegen) hits + the canonical component without knowing about the alias.""" + import esphome.loader as loader_mod + + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) + + canonical = get_component("esp32") + aliased = get_component(_FAKE_ALIAS) + assert canonical is not None + assert aliased is canonical + + +def test_alias_finder_resolves_top_level_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``import esphome.components.`` resolves to the canonical + module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + assert spec is not None + + import esphome.components.esp32 + import esphome.components.esp32_legacy_alias + + assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + + +def test_alias_finder_resolves_submodule_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from esphome.components. import boards`` routes through to + ``esphome.components.esp32.boards`` — same submodule object on both paths. + + The canonical submodule is imported first so its parent module carries + the ``boards`` attribute; ``from import boards`` then resolves + the aliased parent (via the finder) and reads that same attribute, + rather than triggering a fresh file load under the alias name. + ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + assert spec is not None + + from esphome.components.esp32 import boards as canonical_boards + from esphome.components.esp32_legacy_alias import boards as aliased_boards + + assert aliased_boards is canonical_boards + + +def test_alias_finder_ignores_non_components_path() -> None: + """The finder must scope itself to ``esphome.components.`` — + everything else (other esphome submodules, third-party packages) is + left for the normal import machinery.""" + finder = _AliasFinder() + assert finder.find_spec("esphome.core", None) is None + assert finder.find_spec("os.path", None) is None + # `esphome.components` itself (no domain segment) is not a candidate. + assert finder.find_spec("esphome.components", None) is None + # A real, non-aliased component domain defers to normal import machinery + # (no component declares an alias in this repo, so the live map is empty). + assert finder.find_spec("esphome.components.logger", None) is None + + +# --------------------------------------------------------------------------- +# YAML pre-pass: top-level key rename + centralized deprecation warning +# --------------------------------------------------------------------------- +# +# The companion to the loader-side alias map: ``esphome.config`` runs a +# pre-pass over the user's parsed YAML that rewrites legacy top-level keys +# to their canonical names, surfacing a one-shot deprecation warning. These +# tests inject a synthetic alias-metadata map so the rewrite behavior, the +# warning text, and the both-keys-present conflict can be tested in isolation. + + +def _patch_alias_metadata( + monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] +) -> None: + monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) + + +def test_resolve_component_aliases_renames_legacy_key( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A legacy alias key should be renamed to the canonical key and a + deprecation warning citing the removal version logged.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires + config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert "oldcomp" not in config + assert config["newcomp"] == {"board": "x"} + assert any( + "'oldcomp:' top-level key is deprecated" in record.message + and "rename it to 'newcomp:'" in record.message + and "2027.6.0" in record.message + for record in caplog.records + ) + + +def test_resolve_component_aliases_dedupes_warning_within_a_run( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Schema validators can run twice (auto-load discovery + final pass) + so the rename pass must emit the warning only once per alias per run. + Deduped via ``CORE.data``; cleared between runs.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases({"oldcomp": {"board": "a"}}) + _resolve_component_aliases({"oldcomp": {"board": "b"}}) + + matches = [ + r + for r in caplog.records + if "'oldcomp:' top-level key is deprecated" in r.message + ] + assert len(matches) == 1 + + +def test_resolve_component_aliases_rejects_both_keys_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the user has BOTH legacy and canonical keys, silently dropping + one would hide a real misconfiguration. Raise instead.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_canonical_key_after_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The both-keys conflict must be detected even when the canonical key + appears *after* the legacy key in the config (the up-front conflict + scan, not a position-dependent check).""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two different deprecated aliases of the same canonical component is + ambiguous — silently keeping one would hide a misconfiguration.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + { + "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), + "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), + }, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} + with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_preserves_key_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The renamed canonical key keeps the legacy key's original position + rather than being moved to the end of the config.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} + + _resolve_component_aliases(config) + + assert list(config) == ["esphome", "newcomp", "logger"] + + +def test_resolve_component_aliases_no_op_when_no_legacy_keys( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The pre-pass must be a no-op (no warning, no mutation) for configs + that already use canonical keys.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + original = dict(config) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert config == original + assert not any("deprecated" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# ComponentManifest alias properties +# --------------------------------------------------------------------------- + + +def test_component_manifest_alias_properties_default_empty() -> None: + """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` + when the component module declares neither. + + Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the + ``getattr(..., default)`` fallback is actually exercised — a bare mock + auto-creates any attribute on access and would never hit the default.""" + mod = ModuleType("fake_component") + manifest = ComponentManifest(mod) + assert manifest.aliases == [] + assert manifest.alias_removal_version is None + + +def test_component_manifest_alias_properties_read_module_values() -> None: + """The properties surface the module's declared values verbatim.""" + mod = MagicMock() + mod.ALIASES = ["legacy"] + mod.ALIAS_REMOVAL_VERSION = "2027.6.0" + manifest = ComponentManifest(mod) + assert manifest.aliases == ["legacy"] + assert manifest.alias_removal_version == "2027.6.0" + + +# --------------------------------------------------------------------------- +# Real (unpatched) lazy build + cache and remaining scanner branches +# --------------------------------------------------------------------------- + + +def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the real lazy build over the actual components dir (no patch): + the first call scans and caches, the second returns the cached object.""" + monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) + first = loader_mod._get_alias_map() + second = loader_mod._get_alias_map() + assert isinstance(first, dict) + assert first is second # cached, not rebuilt on the second call + + +def test_get_alias_metadata_real_build_and_caches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) + first = loader_mod.get_alias_metadata() + second = loader_mod.get_alias_metadata() + assert isinstance(first, dict) + assert first is second + + +def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: + """Loose files and directories without an ``__init__.py`` are ignored; + only real component packages contribute to the map.""" + (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") + (tmp_path / "initless").mkdir() # a dir, but no __init__.py + _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, _ = _build_alias_map() + + assert alias_map == {"legacy": "realcomp"} + + +def test_read_aliases_ignores_non_assignment_and_complex_targets( + tmp_path: Path, +) -> None: + """Non-assignment statements and assignments to non-Name targets are + skipped; only simple ``NAME = ...`` assignments are read.""" + init = tmp_path / "__init__.py" + init.write_text( + "import os\n" # non-Assign (Import) node -> skipped + "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped + "ALIASES = ['legacy']\n" + ) + aliases, _ = _read_aliases(init, ast) + assert aliases == ["legacy"] + + +# --------------------------------------------------------------------------- +# Finder / loader edge branches +# --------------------------------------------------------------------------- + + +def test_alias_finder_returns_none_when_canonical_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias points at a canonical *target* that doesn't exist, the + finder declines (returns None) and lets normal import machinery report + the missing module.""" + _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) + finder = _AliasFinder() + assert finder.find_spec("esphome.components.broken_alias", None) is None + + +def test_alias_finder_reraises_when_canonical_dependency_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the canonical module exists but fails to import one of its own + dependencies, the finder surfaces that real error instead of masking it + as an unresolved alias (which would silently fall through to a confusing + 'no module named ').""" + _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) + + def boom(name: str) -> None: + raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") + + monkeypatch.setattr("esphome.loader.importlib.import_module", boom) + finder = _AliasFinder() + with pytest.raises(ModuleNotFoundError, match="missing_dep"): + finder.find_spec("esphome.components.some_alias", None) + + +def test_install_alias_finder_is_idempotent() -> None: + """The finder is installed once at import; calling the installer again is + a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" + before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(before) == 1 # installed at module import time + loader_mod._install_alias_finder() + after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(after) == 1 + + +def test_get_component_alias_to_missing_canonical_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias resolves to a canonical component that can't be loaded, + ``get_component`` returns None and caches no bogus manifest.""" + _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) + loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) + + assert get_component("ghost_alias") is None + assert "ghost_alias" not in loader_mod._COMPONENT_CACHE + + +# --------------------------------------------------------------------------- +# YAML pre-pass: empty-map fast path + validate_config integration +# --------------------------------------------------------------------------- + + +def test_resolve_component_aliases_noop_when_no_aliases_declared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no component declares an alias, the pre-pass returns immediately + without inspecting or mutating the config.""" + from esphome.config import _resolve_component_aliases + + monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map + config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} + original = dict(config) + _resolve_component_aliases(config) + assert config == original + + +def _default_component_mock() -> Mock: + """A permissive component mock that validates any config (ALLOW_EXTRA).""" + return Mock( + auto_load=[], + is_platform_component=False, + is_platform=False, + multi_conf=False, + multi_conf_no_default=False, + dependencies=[], + conflicts_with=[], + config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), + ) + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_renames_alias_key( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """End-to-end: a legacy top-level key is renamed to its canonical name + before the rest of ``validate_config`` runs, and validation succeeds. + + A real ``esp32`` target platform is included so ``preload_core_config`` + is satisfied and validation runs to completion (the renamed canonical + key is loaded via the mocked, permissive component).""" + mock_get_component.side_effect = lambda name: _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: { + "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") + }, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "esp32": {"board": "esp32dev"}, + "legacyfoo": {"opt": 1}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert not result.errors, f"unexpected errors: {result.errors}" + assert "newcomp" in result + assert "legacyfoo" not in result + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_reports_alias_conflict_as_error( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """If both the legacy and canonical keys are present, ``validate_config`` + surfaces the conflict as a config error (the ``vol.Invalid`` path).""" + mock_get_component.return_value = _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "newcomp": {"opt": 1}, + "legacyfoo": {"opt": 2}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert result.errors + assert "Both 'legacyfoo:'" in str(result.errors) From ac6a0f34ecbaec6217e5701bcfa8b825ed0aa6f8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:45:30 -0400 Subject: [PATCH 040/343] [esp32] Make ESP-IDF the default toolchain (#16910) --- esphome/components/esp32/__init__.py | 2 +- .../esp32/config/flash_mode_idf.yaml | 1 + tests/component_tests/esp32/test_esp32.py | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5d4b3b8b476..3ffec6b8263 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -964,7 +964,7 @@ def _resolve_toolchain(value: ConfigType) -> ConfigType: # Runs before _detect_variant so downstream validators can rely on # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) return value diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml index 7c7f50a4399..d12d4a734b2 100644 --- a/tests/component_tests/esp32/config/flash_mode_idf.yaml +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -5,5 +5,6 @@ esp32: board: esp32dev flash_mode: qio flash_frequency: 80MHz + toolchain: platformio framework: type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a8b5720a80b..e3311f68602 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -64,6 +64,38 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + # No `toolchain:` set -> the new default for esp32. + (None, Toolchain.ESP_IDF), + # An explicit `toolchain:` still wins over the default. + (Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO), + (Toolchain.ESP_IDF.value, Toolchain.ESP_IDF), + ], +) +def test_esp32_default_toolchain_is_esp_idf( + set_core_config: SetCoreConfigCallable, + config_toolchain: str | None, + expected: Toolchain, +) -> None: + """With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves + to the ESP-IDF toolchain; an explicit `toolchain:` still wins.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + # Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain. + CORE.toolchain = None + config: dict[str, Any] = {"variant": VARIANT_ESP32} + if config_toolchain is not None: + config["toolchain"] = config_toolchain + + CONFIG_SCHEMA(config) + + assert CORE.toolchain == expected + + @pytest.mark.parametrize( ("config", "error_match"), [ From 4b8568e94824341dd49aa8ee05d5f5927f65af9c Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 17 Jun 2026 21:54:29 -0400 Subject: [PATCH 041/343] [socket] bugfix Set wake-request gate flag on LwIP socket receive event (#17010) Co-authored-by: Claude Sonnet 4.6 --- esphome/core/lwip_fast_select.c | 7 +- esphome/core/wake/wake_freertos.cpp | 5 ++ esphome/core/wake/wake_host.cpp | 8 ++ .../fixtures/socket_wake_gate_tcp.yaml | 27 +++++++ .../integration/test_socket_wake_gate_tcp.py | 75 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/socket_wake_gate_tcp.yaml create mode 100644 tests/integration/test_socket_wake_gate_tcp.py diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 36000d4e777..2042c438044 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,8 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +extern void esphome_wake_loop_threadsafe(void); + #ifdef USE_OTA_PLATFORM_ESPHOME static struct netconn *s_ota_listener_conn = NULL; extern void esphome_wake_ota_component_any_context(void); @@ -189,10 +191,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt esphome_wake_ota_component_any_context(); } #endif - TaskHandle_t task = esphome_main_task_handle; - if (task != NULL) { - xTaskNotifyGive(task); - } + esphome_wake_loop_threadsafe(); } } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 0bf700daa89..458ef51f89f 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,4 +30,9 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome +extern "C" void esphome_wake_loop_threadsafe() { + esphome::wake_request_set(); + esphome_main_task_notify(); +} + #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9d2a650ca24..8cb382a77e0 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,6 +123,14 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } + // A socket woke select() early — open the component-phase gate so the + // owning component's loop() drains the data on this tick rather than + // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() + // already set the flag (wake socket fired); required when an application + // socket fired and nothing else set the flag. + if (ret > 0) { + wake_request_set(); + } return; } // ret < 0: error (EINTR is normal, anything else is unexpected). diff --git a/tests/integration/fixtures/socket_wake_gate_tcp.yaml b/tests/integration/fixtures/socket_wake_gate_tcp.yaml new file mode 100644 index 00000000000..4dbf89cbf0d --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_tcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-tcp + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without wake_request_set() being + // called when select() returns due to socket data, the component + // phase would be gated for up to 2000ms after a TCP request arrives. + App.set_loop_interval(2000); + # Let boot transients and API handshake settle. + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + actions: + - action: ping + then: + - logger.log: + format: "PONG" + level: INFO + +logger: + level: INFO diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py new file mode 100644 index 00000000000..2955d2803a5 --- /dev/null +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -0,0 +1,75 @@ +"""Test that a TCP socket receive opens the component-phase gate immediately. + +Regression test for the wake-request flag not being set when select() returns +due to socket data on the host platform (wake_host.cpp wakeable_delay fix). + +The API server's accepted connection sockets use accept_loop_monitored(), so +they are registered with the host select() loop. A service call from the Python +client arrives on that socket. Without the fix, select() returning early did not +set g_wake_requested, so Application::loop()'s Phase B gate stayed closed until +loop_interval_ expired. With the fix, the gate opens immediately. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_tcp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """TCP socket receive must open the component-phase gate immediately, + even with loop_interval_ raised to 2000ms.""" + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "socket-wake-gate-tcp" + + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared — device did not complete boot") + + _, services = await client.list_entities_services() + ping_service = next((s for s in services if s.name == "ping"), None) + assert ping_service is not None, "ping service not found" + + # Execute the service and time how long until PONG appears in logs. + # The request bytes arrive on an accept_loop_monitored() TCP socket, + # which is registered with the host select() loop. + t_send = time.monotonic() + await client.execute_service(ping_service, {}) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared — service did not execute") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the fix the gate stays closed for up to loop_interval_=2000ms. + # With the fix the gate opens on the next tick; 500ms gives ample CI headroom. + assert elapsed_ms < 500, ( + f"Service response took {elapsed_ms:.0f}ms with loop_interval_=2000ms — " + f"expected < 500ms; without the wake-request fix this would take up to 2000ms" + ) From f76dfd579cbe64e619440e04d70f39cf858edc09 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:58:51 +0100 Subject: [PATCH 042/343] [openthread] Add basic Openthread support to Zephyr/nRF52 platform (#16854) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/openthread/__init__.py | 72 +++++++-- esphome/components/openthread/openthread.cpp | 22 ++- esphome/components/openthread/openthread.h | 3 +- .../components/openthread/openthread_esp.cpp | 2 +- .../openthread/openthread_zephyr.cpp | 141 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 7 +- .../openthread/test.nrf52-adafruit.yaml | 5 + 7 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 esphome/components/openthread/openthread_zephyr.cpp create mode 100644 tests/components/openthread/test.nrf52-adafruit.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index bc1e91d6dac..215f9212293 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -10,6 +10,8 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -20,6 +22,7 @@ from esphome.const import ( CONF_OUTPUT_POWER, CONF_USE_ADDRESS, PLATFORM_ESP32, + PlatformFramework, ) from esphome.core import ( CORE, @@ -52,7 +55,6 @@ AUTO_LOAD = ["network"] # Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time # TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", @@ -98,9 +100,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - if tlv := config.get(CONF_TLV): - cg.add_define("USE_OPENTHREAD_TLVS", tlv) - else: + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -128,9 +128,6 @@ def set_sdkconfig_options(config): "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() ) - if config.get(CONF_FORCE_DATASET): - cg.add_define("USE_OPENTHREAD_FORCE_DATASET") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) @@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema( def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: config[CONF_USE_ADDRESS] = f"{CORE.name}.local" + if CORE.using_zephyr and CONF_TLV not in config: + raise cv.Invalid( + "On nRF52, OpenThread credentials must be provided via 'tlv'. " + "Individual parameters (network_key, pan_id, channel, etc.) are not yet supported on this platform." + ) device_type = config.get(CONF_DEVICE_TYPE) poll_period = config.get(CONF_POLL_PERIOD) if ( @@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType: def _require_vfs_select(config): """Register VFS select requirement during config validation.""" - # OpenThread uses esp_vfs_eventfd which requires VFS select support - require_vfs_select() + # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) + if CORE.is_esp32: + require_vfs_select() return config +def _validate_platform(config): + if CORE.using_zephyr: + return config + return only_on_variant( + supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + )(config) + + +def _validate_tlv_hex(value): + s = cv.string_strict(value) + if len(s) % 2 != 0: + raise cv.Invalid("TLV must have an even number of hex characters") + try: + raw = bytes.fromhex(s) + except ValueError as e: + raise cv.Invalid(f"TLV must be valid hex: {e}") from e + if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) + raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") + return s + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.string_strict, + cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( @@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), - only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), + _validate_platform, _validate, _require_vfs_select, ) @@ -227,13 +251,27 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "openthread_esp.cpp": { + PlatformFramework.ESP32_IDF, + }, + "openthread_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): # Re-enable openthread IDF component (excluded by default) - include_builtin_idf_component("openthread") + if CORE.is_esp32: + include_builtin_idf_component("openthread") cg.add_define("USE_OPENTHREAD") + if config.get(CONF_FORCE_DATASET): + cg.add_define("USE_OPENTHREAD_FORCE_DATASET") + if tlv := config.get(CONF_TLV): + cg.add_define("USE_OPENTHREAD_TLVS", tlv) # OpenThread SRP needs access to mDNS services after setup enable_mdns_storage() @@ -252,4 +290,12 @@ async def to_code(config): if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) - set_sdkconfig_options(config) + if CORE.is_esp32: + set_sdkconfig_options(config) + elif CORE.using_zephyr: + zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) + zephyr_add_prj_conf( + f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True + ) + zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c8ffc02131a..102424c62e0 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() { char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); const auto &host_name = App.get_name(); uint16_t host_name_len = host_name.size(); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); return; } @@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() { return; } - // Get mdns services and copy their data (strings are copied with strdup below) + // Get mdns services and copy their data (strdup on ESP32, pool_alloc_ on Zephyr) const auto &mdns_services = this->mdns_->get_services(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); for (const auto &service : mdns_services) { @@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() { // Set service name char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto); - if (full_service.size() > size) { + if (full_service.size() >= size) { ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); continue; } @@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() { // Set instance name (using host_name) string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str()); continue; } @@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() { for (size_t i = 0; i < service.txt_records.size(); i++) { const auto &txt = service.txt_records[i]; // Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_ - // OpenThread SRP client expects the data to persist, so we strdup it + // OpenThread SRP client expects the data to persist, so we copy it const char *value_str = MDNS_STR_ARG(txt.value); txt_entries[i].mKey = MDNS_STR_ARG(txt.key); +#ifndef USE_ZEPHYR txt_entries[i].mValue = reinterpret_cast(strdup(value_str)); txt_entries[i].mValueLength = strlen(value_str); +#else + // strdup is not available on zephyr + // https:// github.com/zephyrproject-rtos/zephyr/issues/22464 + size_t value_len = strlen(value_str); + char *value_copy = reinterpret_cast(this->pool_alloc_(value_len + 1)); + memcpy(value_copy, value_str, value_len + 1); + txt_entries[i].mValue = reinterpret_cast(value_copy); + txt_entries[i].mValueLength = value_len; +#endif } entry->mService.mTxtEntries = txt_entries; entry->mService.mNumTxtEntries = service.txt_records.size(); @@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() { global_openthread_component = nullptr; ESP_LOGD(TAG, "Exit main loop "); int error = this->openthread_stop_(); - if (error != ESP_OK) { + if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); this->teardown_complete_ = true; } diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 96f1abdb924..f1c79fb9cbd 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -43,10 +43,11 @@ class OpenThreadComponent : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } + void set_connected(bool connected) { this->connected_ = connected; } + static void on_state_changed(otChangedFlags flags, void *context); protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 4d88cbd2264..6edaa98524c 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -217,7 +217,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } InstanceLock InstanceLock::try_acquire(int delay) { - if (!global_openthread_component->is_lock_initialized()) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } return InstanceLock(esp_openthread_lock_acquire(delay)); diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp new file mode 100644 index 00000000000..7b9f14ab8ce --- /dev/null +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -0,0 +1,141 @@ +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_NRF52) +#include +#include +#include +#include "openthread.h" +#include "esphome/core/helpers.h" +#include + +static const char *const TAG = "openthread"; + +namespace esphome::openthread { + +static void on_thread_state_changed(otChangedFlags flags, struct openthread_context *ot_context, void *user_data) { + // Delegate connection status tracking to common callback + if (global_openthread_component != nullptr) { + OpenThreadComponent::on_state_changed(flags, global_openthread_component); + } + if (flags & OT_CHANGED_THREAD_ROLE) { + otDeviceRole role = otThreadGetDeviceRole(ot_context->instance); + ESP_LOGI(TAG, "Thread role changed to %s", otThreadDeviceRoleToString(role)); + } + if (flags & OT_CHANGED_THREAD_NETDATA) { + ESP_LOGI(TAG, "Thread network data updated"); + } + if (flags & (OT_CHANGED_THREAD_ROLE | OT_CHANGED_THREAD_NETDATA)) { + char buf[NET_IPV6_ADDR_LEN]; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(ot_context->instance); addr != nullptr; + addr = addr->mNext) { + ESP_LOGI(TAG, " Address: %s", net_addr_ntop(AF_INET6, &addr->mAddress, buf, sizeof(buf))); + } + } +} + +static struct openthread_state_changed_cb ot_state_changed_cb = {.state_changed_cb = on_thread_state_changed}; + +void OpenThreadComponent::setup() { + struct openthread_context *context = openthread_get_default_context(); + this->lock_initialized_ = true; + otOperationalDatasetTlvs dataset = {}; + +#ifndef USE_OPENTHREAD_FORCE_DATASET + otError error = otDatasetGetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); + } +#endif + +#ifdef USE_OPENTHREAD_TLVS + if (dataset.mLength == 0) { + const size_t tlv_chars = sizeof(USE_OPENTHREAD_TLVS) - 1; + if ((tlv_chars % 2) != 0) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string length (must be even, got %zu)", tlv_chars); + this->mark_failed(); + return; + } + + size_t len = tlv_chars / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGE(TAG, "OpenThread TLV too long (max %zu bytes, got %zu bytes)", sizeof(dataset.mTlvs), len); + this->mark_failed(); + return; + } + + size_t parsed = parse_hex(USE_OPENTHREAD_TLVS, tlv_chars, dataset.mTlvs, len); + if (parsed != tlv_chars) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string (expected %zu hex chars, got %zu)", tlv_chars, parsed); + this->mark_failed(); + return; + } + dataset.mLength = len; + } +#endif + if (dataset.mLength > 0) { + otError error = otDatasetSetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set active dataset: %s", otThreadErrorToString(error)); + this->mark_failed(); + return; + } + } + openthread_state_changed_cb_register(context, &ot_state_changed_cb); + openthread_start(context); +} + +void OpenThreadComponent::ot_main() {} + +otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); } + +int OpenThreadComponent::openthread_stop_() { + // OT stack is intentionally left running — no Zephyr stop API. The state callback stays + // registered but is safe (null-checks global_openthread_component). nRF52840 never + // re-enters setup() after teardown so this is functionally correct. + this->teardown_complete_ = true; + return 0; +} + +network::IPAddresses OpenThreadComponent::get_ip_addresses() { + network::IPAddresses addresses; + auto lock = InstanceLock::acquire(); + size_t addr_count = 0; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(openthread_get_default_instance()); + addr != nullptr && addr_count + 1 < addresses.size(); addr = addr->mNext) { + struct in6_addr ip6; + memcpy(&ip6, addr->mAddress.mFields.m8, sizeof(ip6)); + addresses[addr_count + 1] = network::IPAddress(&ip6); + addr_count++; + } + return addresses; +} + +InstanceLock InstanceLock::try_acquire(int delay) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { + return InstanceLock(false); + } + struct openthread_context *ot_context = openthread_get_default_context(); + if (k_mutex_lock(&ot_context->api_lock, K_MSEC(delay)) == 0) { + return InstanceLock(true); + } + return InstanceLock(false); +} + +InstanceLock InstanceLock::acquire() { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_lock(&ot_context->api_lock, K_FOREVER); + return InstanceLock(true); +} + +otInstance *InstanceLock::get_instance() { return openthread_get_default_instance(); } + +InstanceLock::~InstanceLock() { + if (this->owns_) { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_unlock(&ot_context->api_lock); + } +} + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 57f5778d547..bd5f01aa3aa 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True, image: str = "" + name: str, + value: PrjConfValueType, + required: bool = True, + image: str = "", ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): @@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None: # os: ***** USAGE FAULT ***** # os: Illegal load of EXC_RETURN into PC - zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False) CORE.add_job(_cdc_acm_to_code, config) diff --git a/tests/components/openthread/test.nrf52-adafruit.yaml b/tests/components/openthread/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..ac2fe63739c --- /dev/null +++ b/tests/components/openthread/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 From b6763cfaed5dfd1a2d40b7e0d3f8866ac184a1bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:07 +1200 Subject: [PATCH 043/343] [ci] Smoke-test docker image by compiling each target toolchain (#16995) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci-docker.yml | 102 ++++++++++++++++-- docker/test_configs/bk72xx-arduino.yaml | 7 ++ .../test_configs/esp32-arduino-esp-idf.yaml | 10 ++ .../esp32-arduino-platformio.yaml | 10 ++ docker/test_configs/esp32-idf-esp-idf.yaml | 10 ++ docker/test_configs/esp32-idf-platformio.yaml | 10 ++ docker/test_configs/esp8266-arduino.yaml | 7 ++ docker/test_configs/host.yaml | 6 ++ docker/test_configs/ln882x-arduino.yaml | 7 ++ docker/test_configs/nrf52.yaml | 8 ++ docker/test_configs/rp2040-arduino.yaml | 7 ++ docker/test_configs/rtl87xx-arduino.yaml | 7 ++ 12 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 docker/test_configs/bk72xx-arduino.yaml create mode 100644 docker/test_configs/esp32-arduino-esp-idf.yaml create mode 100644 docker/test_configs/esp32-arduino-platformio.yaml create mode 100644 docker/test_configs/esp32-idf-esp-idf.yaml create mode 100644 docker/test_configs/esp32-idf-platformio.yaml create mode 100644 docker/test_configs/esp8266-arduino.yaml create mode 100644 docker/test_configs/host.yaml create mode 100644 docker/test_configs/ln882x-arduino.yaml create mode 100644 docker/test_configs/nrf52.yaml create mode 100644 docker/test_configs/rp2040-arduino.yaml create mode 100644 docker/test_configs/rtl87xx-arduino.yaml diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7d4b8503567..373cd905b19 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,25 +1,38 @@ --- name: CI for docker images -# Only run when docker paths change +# Only run on PRs that touch the docker image, its build inputs, or any code +# whose toolchain the compile smoke test exercises (core + target platforms). on: - push: - branches: [dev, beta, release] - paths: - - "docker/**" - - ".github/workflows/ci-docker.yml" - - "requirements*.txt" - - "platformio.ini" - - "script/platformio_install_deps.py" - pull_request: paths: + # Docker image and its build inputs. - "docker/**" - ".github/workflows/ci-docker.yml" - "requirements*.txt" + - "pyproject.toml" - "platformio.ini" + - "esphome/idf_component.yml" - "script/platformio_install_deps.py" + # Core, build pipeline, toolchain, and target-platform changes can change + # how a toolchain is set up or built, so re-run the per-toolchain compile + # smoke test when they change. + - "esphome/core/**" + - "esphome/writer.py" + - "esphome/build_gen/**" + - "esphome/espidf/**" + - "esphome/platformio/**" + - "esphome/components/bk72xx/**" + - "esphome/components/esp32/**" + - "esphome/components/esp8266/**" + - "esphome/components/host/**" + - "esphome/components/libretiny/**" + - "esphome/components/ln882x/**" + - "esphome/components/nrf52/**" + - "esphome/components/rp2040/**" + - "esphome/components/rtl87xx/**" + - "esphome/components/zephyr/**" permissions: contents: read # actions/checkout only @@ -96,7 +109,26 @@ jobs: --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ --registry ghcr \ - build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }} + + # The amd64 "docker" image is also loaded locally (above) and handed to + # compile-test as an artifact, so the smoke test reuses this build instead + # of building the image a second time. Using an artifact (rather than the + # pushed image) keeps it working for fork PRs, which never push to ghcr.io. + - name: Export image for compile-test + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + + - name: Upload compile-test image artifact + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # The tar is already gzipped, so upload it as-is. archive: false skips + # the redundant zip and makes the file name the artifact name (the + # `name` input is ignored in that mode). + path: compile-test-image.tar.gz + retention-days: 1 + archive: false manifest: name: Push ${{ matrix.build_type }} manifest to ghcr.io @@ -135,3 +167,51 @@ jobs: --build-type "${{ matrix.build_type }}" \ --registry ghcr \ manifest + + # Smoke-test the built image by compiling one minimal config per target + # platform / toolchain. This catches missing system dependencies in the image + # that only surface when a given toolchain is downloaded and run. The image is + # the amd64 "docker" build produced by check-docker (shared as an artifact). + compile-test: + name: Compile ${{ matrix.id }} + needs: check-docker + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to load the test configs + strategy: + fail-fast: false + # Cap concurrency so this smoke test doesn't hog all the shared runners. + max-parallel: 2 + matrix: + # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) + # share a toolchain bundle, so esp32 is exercised on the base variant + # across the full framework x toolchain cross-product (arduino/esp-idf + # framework, each built with the platformio and native esp-idf + # toolchains) so both toolchains stay covered regardless of which one is + # the default. + id: + - esp8266-arduino + - esp32-arduino-platformio + - esp32-arduino-esp-idf + - esp32-idf-platformio + - esp32-idf-esp-idf + - rp2040-arduino + - bk72xx-arduino + - rtl87xx-arduino + - ln882x-arduino + - nrf52 + - host + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Download image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: compile-test-image.tar.gz + - name: Load image + run: docker load --input compile-test-image.tar.gz + - name: Compile ${{ matrix.id }} + run: | + docker run --rm \ + -v "${{ github.workspace }}/docker/test_configs:/config" \ + "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ + compile "${{ matrix.id }}.yaml" diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml new file mode 100644 index 00000000000..138aa9e282c --- /dev/null +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-bk72xx-arduino + +bk72xx: + board: generic-bk7231n-qfn32-tuya + +logger: diff --git a/docker/test_configs/esp32-arduino-esp-idf.yaml b/docker/test_configs/esp32-arduino-esp-idf.yaml new file mode 100644 index 00000000000..fbc68aff0c3 --- /dev/null +++ b/docker/test_configs/esp32-arduino-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-idf + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-arduino-platformio.yaml b/docker/test_configs/esp32-arduino-platformio.yaml new file mode 100644 index 00000000000..e216c020599 --- /dev/null +++ b/docker/test_configs/esp32-arduino-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-pio + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp32-idf-esp-idf.yaml b/docker/test_configs/esp32-idf-esp-idf.yaml new file mode 100644 index 00000000000..b180aa9c0a4 --- /dev/null +++ b/docker/test_configs/esp32-idf-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-idf + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-idf-platformio.yaml b/docker/test_configs/esp32-idf-platformio.yaml new file mode 100644 index 00000000000..5aec23e40d2 --- /dev/null +++ b/docker/test_configs/esp32-idf-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-pio + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml new file mode 100644 index 00000000000..80b52260e4d --- /dev/null +++ b/docker/test_configs/esp8266-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-esp8266-arduino + +esp8266: + board: d1_mini + +logger: diff --git a/docker/test_configs/host.yaml b/docker/test_configs/host.yaml new file mode 100644 index 00000000000..9f990693049 --- /dev/null +++ b/docker/test_configs/host.yaml @@ -0,0 +1,6 @@ +esphome: + name: docker-test-host + +host: + +logger: diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml new file mode 100644 index 00000000000..4cff3a48837 --- /dev/null +++ b/docker/test_configs/ln882x-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-ln882x-arduino + +ln882x: + board: generic-ln882hki + +logger: diff --git a/docker/test_configs/nrf52.yaml b/docker/test_configs/nrf52.yaml new file mode 100644 index 00000000000..d6337149cc8 --- /dev/null +++ b/docker/test_configs/nrf52.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-nrf52 + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: diff --git a/docker/test_configs/rp2040-arduino.yaml b/docker/test_configs/rp2040-arduino.yaml new file mode 100644 index 00000000000..4b5df11d875 --- /dev/null +++ b/docker/test_configs/rp2040-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rp2040-arduino + +rp2040: + variant: rp2040 + +logger: diff --git a/docker/test_configs/rtl87xx-arduino.yaml b/docker/test_configs/rtl87xx-arduino.yaml new file mode 100644 index 00000000000..e8d9cf75035 --- /dev/null +++ b/docker/test_configs/rtl87xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rtl87xx-arduino + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: From 3a1a8a89559477cbab10c5b7bd73dacdd8edefef Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:22 +1200 Subject: [PATCH 044/343] [ci] Fail CI Status job when workflow is cancelled (#17024) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b1032bcde7..aca6d9007a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1369,4 +1369,7 @@ jobs: # 1. The target branch has a build issue independent of this PR # 2. This PR fixes a build issue on the target branch # In either case, we only care that the PR branch builds successfully. - echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' + # Every other job must have succeeded or been skipped; a "cancelled" or + # "failure" result fails this check so CI is not reported green when the + # workflow was cancelled. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result == "success" or .result == "skipped")' From c2784c9fd8a388a4edbc2ec208101fc72be9686a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 17 Jun 2026 22:09:39 -0500 Subject: [PATCH 045/343] [esp32] Consolidate network/coexistence sdkconfig into a single reconciler (#17008) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/esp32/__init__.py | 126 ++++++++++- esphome/components/esp32/const.py | 1 + esphome/components/esp32_ble/__init__.py | 10 +- .../components/esp32_ble_beacon/__init__.py | 5 +- .../components/esp32_ble_server/__init__.py | 4 +- .../components/esp32_ble_tracker/__init__.py | 10 +- esphome/components/ethernet/__init__.py | 8 +- esphome/components/wifi/__init__.py | 9 +- .../esp32/config/network_ethernet_only.yaml | 17 ++ .../config/network_wifi_ble_coexistence.yaml | 14 ++ .../esp32/config/network_wifi_only.yaml | 11 + tests/component_tests/esp32/test_esp32.py | 195 +++++++++++++++++- 12 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/esp32/config/network_ethernet_only.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml create mode 100644 tests/component_tests/esp32/config/network_wifi_only.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3ffec6b8263..aee86a0554e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -69,6 +69,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -597,6 +598,59 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value +@dataclass +class NetworkSdkconfigData: + """Inputs for the network-related esp32 sdkconfig flags, reconciled at FINAL. + + Components call the request_*() helpers below (and esp32's own to_code fills + in enable_lwip_dhcp_server) instead of setting the WiFi/Ethernet/Bluetooth + sdkconfig flags directly; the single _reconcile_network_sdkconfig() coroutine + then decides the final values so they no longer depend on call order. + """ + + wifi: bool = False # WiFi component active (STA and/or AP) + wifi_ap: bool = False # WiFi AP mode configured + ethernet: bool = False # Ethernet component active + bluetooth: bool = False # any BLE component active + ble_42: bool = False # BLE 4.2 features needed + software_coexistence: bool = False # WiFi/BT software coexistence requested + # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) + enable_lwip_dhcp_server: bool | None = None + + +def _network_sdkconfig() -> NetworkSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_NETWORK_SDKCONFIG not in data: + data[KEY_NETWORK_SDKCONFIG] = NetworkSdkconfigData() + return data[KEY_NETWORK_SDKCONFIG] + + +def request_wifi(ap: bool = False) -> None: + """Request the WiFi stack. Pass ap=True when AP mode is configured.""" + net = _network_sdkconfig() + net.wifi = True + if ap: + net.wifi_ap = True + + +def request_ethernet() -> None: + """Request the Ethernet stack.""" + _network_sdkconfig().ethernet = True + + +def request_bluetooth(ble_42: bool = False) -> None: + """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" + net = _network_sdkconfig() + net.bluetooth = True + if ble_42: + net.ble_42 = True + + +def request_software_coexistence() -> None: + """Request WiFi/BT software coexistence (only valid alongside WiFi).""" + _network_sdkconfig().software_coexistence = True + + def add_idf_component( *, name: str, @@ -1847,6 +1901,61 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_network_sdkconfig() -> None: + """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. + + Single decision point for flags that multiple components used to set + directly (and sometimes with conflicting values). Runs at FINAL priority so + every request_*() call (made from the various components' to_code at their + own priorities) is seen first. A user-supplied sdkconfig_options value + always takes precedence. + """ + net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData()) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + is_arduino = CORE.using_arduino + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # Bluetooth: only ever enable when requested. The IDF default is off and + # nothing sets these False today, so never write False here. + if net.bluetooth: + set_opt("CONFIG_BT_ENABLED", True) + if net.ble_42: + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + + # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi + # relies on the IDF default (enabled), so it is never written True here. + wifi_disabled = net.ethernet and not net.wifi + if wifi_disabled: + set_opt("CONFIG_ESP_WIFI_ENABLED", False) + + # Software coexistence: enable when requested (the schema only allows it + # alongside WiFi). Disable only in the Ethernet-without-WiFi case. + if net.software_coexistence: + set_opt("CONFIG_SW_COEXIST_ENABLE", True) + elif wifi_disabled: + set_opt("CONFIG_SW_COEXIST_ENABLE", False) + + # SoftAP support: drop it when WiFi is used without AP mode (IDF only). + if not is_arduino and net.wifi and not net.wifi_ap: + set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + + # LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not + # coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server + # option is set to false, unless Arduino+Ethernet needs the symbols to compile. + wifi_wants_dhcps_off = not is_arduino and net.wifi and not net.wifi_ap + dhcp_server_disabled_by_option = net.enable_lwip_dhcp_server is False + arduino_eth_exclusion = is_arduino and net.ethernet + if ( + wifi_wants_dhcps_off or dhcp_server_disabled_by_option + ) and not arduino_eth_exclusion: + set_opt("CONFIG_LWIP_DHCPS", False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -2171,14 +2280,12 @@ async def to_code(config): for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []): include_builtin_idf_component(component_name) - # DHCP server: only disable if explicitly set to false - # WiFi component handles its own optimization when AP mode is not used - # When using Arduino with Ethernet, DHCP server functions must be available - # for the Network library to compile, even if not actively used - if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations - ): - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # DHCP server (CONFIG_LWIP_DHCPS) is reconciled in _reconcile_network_sdkconfig + # together with the WiFi component's own AP-mode optimization; record the user's + # advanced tristate (True/False/None) for it to consume at FINAL priority. + _network_sdkconfig().enable_lwip_dhcp_server = advanced.get( + CONF_ENABLE_LWIP_DHCP_SERVER + ) if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: @@ -2397,6 +2504,9 @@ async def to_code(config): # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) + # FINAL priority: runs after every network/coexistence request_*() call + CORE.add_job(_reconcile_network_sdkconfig) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 322054ea912..83fcfd233e7 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -16,6 +16,7 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_IDF_VERSION = "idf_version" +KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c7b6b40394c..c9fb42fde4a 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -8,7 +8,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM -from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + const, + get_esp32_variant, + request_bluetooth, +) from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv from esphome.const import ( @@ -599,8 +604,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8052c13596b..7a59cce19b4 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import CONF_BLE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID @@ -86,5 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d45f2d9df25..ea2a9667d72 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED @@ -632,7 +632,7 @@ async def to_code(config): ) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() @automation.register_action( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d758b400c4f..e4139bed651 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -6,7 +6,11 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble, ota -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + request_bluetooth, + request_software_coexistence, +) from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, BTLoggers, @@ -315,9 +319,9 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() if config.get(CONF_SOFTWARE_COEXISTENCE): - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + request_software_coexistence() # https://github.com/espressif/esp-idf/issues/4101 # https://github.com/espressif/esp-idf/issues/2503 # Match arduino CONFIG_BTU_TASK_STACK_SIZE diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 784f5dee8cc..f6afc30ff23 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -540,6 +540,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_ethernet, ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: @@ -586,10 +587,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Disable WiFi when using Ethernet to save memory - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Register Ethernet with the esp32 sdkconfig reconciler, which disables the + # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b7719c80d13..080a7bb97ba 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( const, get_esp32_variant, only_on_variant, + request_wifi, ) from esphome.components.network import ( has_high_performance_networking, @@ -594,9 +595,11 @@ async def to_code(config): ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) cg.add_define("USE_WIFI_AP") - elif CORE.is_esp32 and not CORE.using_arduino: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + + # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which + # drops SoftAP support / the LWIP DHCP server when AP mode is unused. + if CORE.is_esp32: + request_wifi(ap=CONF_AP in config) # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: diff --git a/tests/component_tests/esp32/config/network_ethernet_only.yaml b/tests/component_tests/esp32/config/network_ethernet_only.yaml new file mode 100644 index 00000000000..73d11e0a13d --- /dev/null +++ b/tests/component_tests/esp32/config/network_ethernet_only.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz diff --git a/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml new file mode 100644 index 00000000000..9aff46b7c40 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +esp32_ble_tracker: + software_coexistence: true diff --git a/tests/component_tests/esp32/config/network_wifi_only.yaml b/tests/component_tests/esp32/config/network_wifi_only.yaml new file mode 100644 index 00000000000..61dfde3e039 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e3311f68602..bdba981c44d 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,14 +2,25 @@ Test ESP32 configuration """ +import asyncio from collections.abc import Callable from pathlib import Path from typing import Any import pytest -from esphome.components.esp32 import VARIANT_ESP32, VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANTS, + NetworkSdkconfigData, + _reconcile_network_sdkconfig, +) +from esphome.components.esp32.const import ( + KEY_ESP32, + KEY_NETWORK_SDKCONFIG, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv from esphome.const import ( @@ -343,3 +354,183 @@ def test_flash_mode_unset_leaves_defaults( assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) assert "board_build.flash_mode" not in CORE.platformio_options assert "board_build.f_flash" not in CORE.platformio_options + + +@pytest.mark.parametrize( + ("framework", "net", "preset", "expected"), + [ + # --- IDF: single-interface cases (must match pre-refactor behavior) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_no_ap", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, wifi_ap=True), + {}, + {}, + id="idf_wifi_ap_leaves_softap_dhcps", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="idf_ethernet_only", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, bluetooth=True, ble_42=True, software_coexistence=True + ), + {}, + { + "CONFIG_BT_ENABLED": True, + "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_SW_COEXIST_ENABLE": True, + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_ble_tracker_coexistence", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(bluetooth=True), + {}, + {"CONFIG_BT_ENABLED": True}, + id="idf_ble_server_only_no_ble42", + ), + # --- IDF: user sdkconfig_options always win --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_user_override_wins", + ), + # --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False + ), + {}, + {"CONFIG_LWIP_DHCPS": False}, + id="idf_user_disables_dhcps_with_ap", + ), + # --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_and_ethernet_keeps_wifi_enabled", + ), + # --- Arduino: SoftAP/DHCPS disable is IDF-only --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(wifi=True), + {}, + {}, + id="arduino_wifi_no_ap_untouched", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_only_disables_wifi", + ), + # --- Arduino + Ethernet: DHCPS stays available even if user disabled it --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_dhcps_exclusion", + ), + ], +) +def test_reconcile_network_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + net: NetworkSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence + sdkconfig flags from the requests recorded in NetworkSdkconfigData.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_NETWORK_SDKCONFIG: net, + } + + asyncio.run(_reconcile_network_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_network_wifi_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler + after wifi's request_wifi(), disabling SoftAP support and the DHCP server.""" + generate_main(component_config_path("network_wifi_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi stack stays enabled (no ethernet) and no Bluetooth requested. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_BT_ENABLED" not in sdkconfig + + +def test_network_ethernet_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: ethernet's request_ethernet() makes the reconciler disable the + WiFi stack and coexistence when WiFi is absent.""" + generate_main(component_config_path("network_ethernet_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False + + +def test_network_wifi_ble_coexistence_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to + BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP).""" + generate_main(component_config_path("network_wifi_ble_coexistence.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_BT_ENABLED") is True + assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi present alongside BT -> WiFi stack must stay enabled. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig From bd9375117a91d86854a81f0ba7090b2678309a92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jun 2026 22:11:24 -0500 Subject: [PATCH 046/343] [core] Honor transferred address cache in has_resolvable_address (#17025) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 6 ++++++ tests/unit_tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834b..27dd878495d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 03c005dc276..e44f746a750 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -689,6 +689,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3154,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" From d4b642608793a06249656ea16f26d5d97bcb58e6 Mon Sep 17 00:00:00 2001 From: "Thomas A." Date: Thu, 18 Jun 2026 05:12:22 +0200 Subject: [PATCH 047/343] [esp32] Pin Names for Seeed XIAO C3 / C6 / S3 (#17002) Co-authored-by: Thomas A <1294885+zeroflow@users.noreply.github.com> Co-authored-by: Claude --- esphome/components/esp32/boards.py | 90 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 6062631d984..729b0c89ab6 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1240,6 +1240,43 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + # Source: https://wiki.seeedstudio.com/XIAO_ESP32C3_Getting_Started/ + # The XIAO ESP32-C3 has no user-controllable LED (only a hardwired charge + # LED), so LED/LED_BUILTIN are intentionally omitted. The Ax keys override + # the incorrect ESP32_BASE_PINS A* fallback (which otherwise makes pin: A0 + # resolve to phantom GPIO36 and pin: A1/A2 raise cv.Invalid). + "seeed_xiao_esp32c3": { + "D0": 2, + "D1": 3, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 7, + "D6": 21, + "D7": 20, + "D8": 8, + "D9": 9, + "D10": 10, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "TX": 21, + "RX": 20, + "SDA": 6, + "SCL": 7, + "SCK": 8, + "MISO": 9, + "MOSI": 10, + "A0": 2, + "A1": 3, + "A2": 4, + "A3": 5, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/ + # The Ax keys override the incorrect ESP32_BASE_PINS A* fallback (which + # otherwise makes pin: A0 resolve to phantom GPIO36). "seeed_xiao_esp32c6": { "D0": 0, "D1": 1, @@ -1257,10 +1294,59 @@ ESP32_BOARD_PINS = { "MTDI": 5, "MTMS": 4, "BOOT": 9, - "LED": 8, - "LED_BUILTIN": 8, + "LED": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 + "LED_BUILTIN": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 "RF_SWITCH_EN": 3, "RF_ANT_SELECT": 14, + "TX": 16, + "RX": 17, + "SDA": 22, + "SCL": 23, + "SCK": 19, + "MISO": 20, + "MOSI": 18, + "A0": 0, + "A1": 1, + "A2": 2, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/ + # LED (GPIO21) is active-LOW; BOOT=GPIO0 is the standard ESP32-S3 strapping + # pin. The Ax keys override the incorrect ESP32_BASE_PINS A* fallback for the + # published silkscreen set. A6/A7 are intentionally absent (D6/D7 = GPIO43/44 + # have no ADC); because ESP32_BASE_PINS already defines A6=34/A7=35, pin: A6/A7 + # still resolve to those classic-ESP32 phantom values via the base-pins + # fallback (a disclosed residual, not fixable without editing ESP32_BASE_PINS). + "seeed_xiao_esp32s3": { + "D0": 1, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 5, + "D5": 6, + "D6": 43, + "D7": 44, + "D8": 7, + "D9": 8, + "D10": 9, + "BOOT": 0, + "LED": 21, + "LED_BUILTIN": 21, + "TX": 43, + "RX": 44, + "SDA": 5, + "SCL": 6, + "SCK": 7, + "MISO": 8, + "MOSI": 9, + "A0": 1, + "A1": 2, + "A2": 3, + "A3": 4, + "A4": 5, + "A5": 6, + "A8": 7, + "A9": 8, + "A10": 9, }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, From 9ace0ffb262a3cbbc24822a3ff036d1116fd39ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:15 -0400 Subject: [PATCH 048/343] Bump pylint from 4.0.5 to 4.0.6 (#16983) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5ba806a2f57..438d6cd0058 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.5 +pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.15.17 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From 26c42af35478ff74d7a02ef7ae9645508b0e71d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:46 -0400 Subject: [PATCH 049/343] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1 (#16986) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a8..6ff846e4b2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 3b2564bbf3b7a31fae5794185fb740aca6b5cd3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:56 -0400 Subject: [PATCH 050/343] Bump cryptography from 48.0.1 to 49.0.0 (#16985) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ef3df60ffc..efb5ec8723a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==48.0.1 +cryptography==49.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From c63bed8c217ebb127c7e9ffdf776c08207db7d26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:16:04 -0400 Subject: [PATCH 051/343] Bump pytest from 9.0.3 to 9.1.0 (#16981) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 438d6cd0058..fc9681921a6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.3 +pytest==9.1.0 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 2b38e4b7e2f0cfbd49a782855ff94373100916f5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 17 Jun 2026 23:23:18 -0400 Subject: [PATCH 052/343] [audio] Bump microMP3 to v0.3.0 (#17009) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/audio/__init__.py | 6 +++--- esphome/idf_component.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2aceff0c97e..091f496e333 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,11 +395,11 @@ 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.3") + add_idf_component(name="esphome/micro-mp3", ref="0.3.0") _emit_memory_pair( data.mp3.buffer_memory, - "CONFIG_MP3_DECODER_PREFER_PSRAM", - "CONFIG_MP3_DECODER_PREFER_INTERNAL", + "CONFIG_MICRO_MP3_PREFER_PSRAM", + "CONFIG_MICRO_MP3_PREFER_INTERNAL", ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5f3000e52d0..b3b670d77b4 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.3 + version: 0.3.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 11deff2bed04b9c887c311889cd35abb60efec3d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:03 +1200 Subject: [PATCH 053/343] Mark configurable classes as final (1/21: a01nyub-aqi) (#16952) --- esphome/components/a01nyub/a01nyub.h | 2 +- esphome/components/a02yyuw/a02yyuw.h | 2 +- esphome/components/a4988/a4988.h | 2 +- .../absolute_humidity/absolute_humidity.h | 2 +- esphome/components/ac_dimmer/ac_dimmer.h | 2 +- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc128s102/adc128s102.h | 6 +++--- .../adc128s102/sensor/adc128s102_sensor.h | 8 ++++---- .../addressable_light_display.h | 2 +- esphome/components/ade7880/ade7880.h | 2 +- esphome/components/ade7953_i2c/ade7953_i2c.h | 2 +- esphome/components/ads1115/ads1115.h | 2 +- .../ads1115/sensor/ads1115_sensor.h | 8 ++++---- esphome/components/ads1118/ads1118.h | 6 +++--- .../ads1118/sensor/ads1118_sensor.h | 8 ++++---- esphome/components/ags10/ags10.h | 6 +++--- esphome/components/aht10/aht10.h | 2 +- esphome/components/aic3204/aic3204.h | 2 +- esphome/components/aic3204/automation.h | 2 +- .../airthings_ble/airthings_listener.h | 2 +- .../airthings_wave_mini/airthings_wave_mini.h | 2 +- .../airthings_wave_plus/airthings_wave_plus.h | 2 +- .../alarm_control_panel/automation.h | 14 +++++++------- esphome/components/alpha3/alpha3.h | 2 +- esphome/components/am2315c/am2315c.h | 2 +- esphome/components/am2320/am2320.h | 2 +- esphome/components/am43/cover/am43_cover.h | 2 +- esphome/components/am43/sensor/am43_sensor.h | 2 +- .../analog_threshold_binary_sensor.h | 2 +- esphome/components/animation/animation.h | 8 ++++---- esphome/components/anova/anova.h | 2 +- esphome/components/apds9306/apds9306.h | 2 +- esphome/components/apds9960/apds9960.h | 2 +- esphome/components/api/api_server.h | 2 +- .../components/api/homeassistant_service.h | 2 +- esphome/components/api/user_services.h | 19 ++++++++++--------- esphome/components/aqi/aqi_sensor.h | 2 +- 37 files changed, 70 insertions(+), 69 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h index 5c0d20bd378..69636eb8e4c 100644 --- a/esphome/components/a01nyub/a01nyub.h +++ b/esphome/components/a01nyub/a01nyub.h @@ -8,7 +8,7 @@ namespace esphome::a01nyub { -class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h index 693bcfd03c6..2e71651301e 100644 --- a/esphome/components/a02yyuw/a02yyuw.h +++ b/esphome/components/a02yyuw/a02yyuw.h @@ -8,7 +8,7 @@ namespace esphome::a02yyuw { -class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h index 04040241c0f..f50b5926c1c 100644 --- a/esphome/components/a4988/a4988.h +++ b/esphome/components/a4988/a4988.h @@ -6,7 +6,7 @@ namespace esphome::a4988 { -class A4988 : public stepper::Stepper, public Component { +class A4988 final : public stepper::Stepper, public Component { public: void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; } void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; } diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h index be28d3dc509..9989bb17fc8 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.h +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation { }; /// This class implements calculation of absolute humidity from temperature and relative humidity. -class AbsoluteHumidityComponent : public sensor::Sensor, public Component { +class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index 6bfcf0bdb5b..783a9d7e246 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -41,7 +41,7 @@ struct AcDimmerDataStore { #endif }; -class AcDimmer : public output::FloatOutput, public Component { +class AcDimmer final : public output::FloatOutput, public Component { public: void setup() override; diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 676940eca12..03de6f8b4b1 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -54,7 +54,7 @@ template class Aggregator { SamplingMode mode_{SamplingMode::AVG}; }; -class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: /// Update the sensor's state by reading the current ADC value. /// This method is called periodically based on the update interval. diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h index f04ed87b2af..7d6355815e6 100644 --- a/esphome/components/adc128s102/adc128s102.h +++ b/esphome/components/adc128s102/adc128s102.h @@ -6,9 +6,9 @@ namespace esphome::adc128s102 { -class ADC128S102 : public Component, - public spi::SPIDevice { +class ADC128S102 final : public Component, + public spi::SPIDevice { public: ADC128S102() = default; diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h index c840102380f..3c42e709f27 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h @@ -9,10 +9,10 @@ namespace esphome::adc128s102 { -class ADC128S102Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class ADC128S102Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: ADC128S102Sensor(uint8_t channel); diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 917d334f05f..39d62b87335 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -9,7 +9,7 @@ namespace esphome::addressable_light { -class AddressableLightDisplay : public display::DisplayBuffer { +class AddressableLightDisplay final : public display::DisplayBuffer { public: light::AddressableLight *get_light() const { return this->light_; } diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 53f501dee26..12be0849ffa 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -65,7 +65,7 @@ struct ADE7880Store { static void gpio_intr(ADE7880Store *arg); }; -class ADE7880 : public i2c::I2CDevice, public PollingComponent { +class ADE7880 final : public i2c::I2CDevice, public PollingComponent { public: void set_irq0_pin(InternalGPIOPin *pin) { this->irq0_pin_ = pin; } void set_irq1_pin(InternalGPIOPin *pin) { this->irq1_pin_ = pin; } diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h index 74d7e3e7cce..0b368a73ee9 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.h +++ b/esphome/components/ade7953_i2c/ade7953_i2c.h @@ -10,7 +10,7 @@ namespace esphome::ade7953_i2c { -class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { +class AdE7953I2c final : public ade7953_base::ADE7953, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h index b1eed68aff2..0b7f7ae7005 100644 --- a/esphome/components/ads1115/ads1115.h +++ b/esphome/components/ads1115/ads1115.h @@ -43,7 +43,7 @@ enum ADS1115Samplerate { ADS1115_860SPS = 0b111 }; -class ADS1115Component : public Component, public i2c::I2CDevice { +class ADS1115Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h index 3b82c153dd5..ecc8fb7af8f 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.h +++ b/esphome/components/ads1115/sensor/ads1115_sensor.h @@ -11,10 +11,10 @@ namespace esphome::ads1115 { /// Internal holder class that is in instance of Sensor so that the hub can create individual sensors. -class ADS1115Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1115Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; void set_multiplexer(ADS1115Multiplexer multiplexer) { this->multiplexer_ = multiplexer; } diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h index ef125a0b44b..275933c70d7 100644 --- a/esphome/components/ads1118/ads1118.h +++ b/esphome/components/ads1118/ads1118.h @@ -26,9 +26,9 @@ enum ADS1118Gain { ADS1118_GAIN_0P256 = 0b101, }; -class ADS1118 : public Component, - public spi::SPIDevice { +class ADS1118 final : public Component, + public spi::SPIDevice { public: ADS1118() = default; void setup() override; diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h index b929e75c62d..8987dba0732 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.h +++ b/esphome/components/ads1118/sensor/ads1118_sensor.h @@ -10,10 +10,10 @@ namespace esphome::ads1118 { -class ADS1118Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1118Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 703acd5228c..8ebc8da544a 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -7,7 +7,7 @@ namespace esphome::ags10 { -class AGS10Component : public PollingComponent, public i2c::I2CDevice { +class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** * Sets TVOC sensor. @@ -100,7 +100,7 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction : public Action, public Parented { +template class AGS10NewI2cAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, new_address) @@ -116,7 +116,7 @@ enum AGS10SetZeroPointActionMode { CUSTOM_VALUE, }; -template class AGS10SetZeroPointAction : public Action, public Parented { +template class AGS10SetZeroPointAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, value) TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index 7b9b1761c4d..e99ba6fb98a 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -10,7 +10,7 @@ namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; -class AHT10Component : public PollingComponent, public i2c::I2CDevice { +class AHT10Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h index 9b8c7928246..ae99a8f4d6a 100644 --- a/esphome/components/aic3204/aic3204.h +++ b/esphome/components/aic3204/aic3204.h @@ -61,7 +61,7 @@ static const uint8_t AIC3204_ADC_PTM = 0x3D; // Register 61 - ADC Power Tu static const uint8_t AIC3204_AN_IN_CHRG = 0x47; // Register 71 - Analog Input Quick Charging Config static const uint8_t AIC3204_REF_STARTUP = 0x7B; // Register 123 - Reference Power Up Config -class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class AIC3204 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h index 50ae03edbd9..f0f88566145 100644 --- a/esphome/components/aic3204/automation.h +++ b/esphome/components/aic3204/automation.h @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -template class SetAutoMuteAction : public Action { +template class SetAutoMuteAction final : public Action { public: explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 707e9c3f210..8105ac32eb1 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -7,7 +7,7 @@ namespace esphome::airthings_ble { -class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h index 910ac902390..c41dde15c9d 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h @@ -12,7 +12,7 @@ static const char *const SERVICE_UUID = "b42e3882-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e3ef4-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWaveMini final : public airthings_wave_base::AirthingsWaveBase { public: AirthingsWaveMini(); diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index 6f51f3c65ac..af355e45d63 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -19,7 +19,7 @@ static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11 static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e50d8-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWavePlus final : public airthings_wave_base::AirthingsWaveBase { public: void setup() override; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 022d2650d2d..dcb5121c60f 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction : public Action { +template class ArmAwayAction final : public Action { public: explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -39,7 +39,7 @@ template class ArmAwayAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmHomeAction : public Action { +template class ArmHomeAction final : public Action { public: explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -51,7 +51,7 @@ template class ArmHomeAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmNightAction : public Action { +template class ArmNightAction final : public Action { public: explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -63,7 +63,7 @@ template class ArmNightAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class DisarmAction : public Action { +template class DisarmAction final : public Action { public: explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -75,7 +75,7 @@ template class DisarmAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class PendingAction : public Action { +template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -85,7 +85,7 @@ template class PendingAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class TriggeredAction : public Action { +template class TriggeredAction final : public Action { public: explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -95,7 +95,7 @@ template class TriggeredAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class AlarmControlPanelCondition : public Condition { +template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} bool check(const Ts &...x) override { diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index c63129031ad..5a5b01ac0b3 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -31,7 +31,7 @@ static const int16_t GENI_RESPONSE_POWER_OFFSET = 12; static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20; -class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 5a959af4c37..73dc0d87587 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -27,7 +27,7 @@ namespace esphome::am2315c { -class AM2315C : public PollingComponent, public i2c::I2CDevice { +class AM2315C final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index ddb5c6f1653..f92156b1542 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -6,7 +6,7 @@ namespace esphome::am2320 { -class AM2320Component : public PollingComponent, public i2c::I2CDevice { +class AM2320Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h index aa48aced158..be7af59adeb 100644 --- a/esphome/components/am43/cover/am43_cover.h +++ b/esphome/components/am43/cover/am43_cover.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43Component : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { +class Am43Component final : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 9198a5cbcbd..944681bb607 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index c768f1f82d6..a4df00ff05f 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -7,7 +7,7 @@ namespace esphome::analog_threshold { -class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor { +class AnalogThresholdBinarySensor final : public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index ca800ad9311..64cddbf09c9 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -5,7 +5,7 @@ namespace esphome::animation { -class Animation : public image::Image { +class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, image::Transparency transparent); @@ -35,7 +35,7 @@ class Animation : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction : public Action { +template class AnimationNextFrameAction final : public Action { public: AnimationNextFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->next_frame(); } @@ -44,7 +44,7 @@ template class AnimationNextFrameAction : public Action { Animation *parent_; }; -template class AnimationPrevFrameAction : public Action { +template class AnimationPrevFrameAction final : public Action { public: AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->prev_frame(); } @@ -53,7 +53,7 @@ template class AnimationPrevFrameAction : public Action { Animation *parent_; }; -template class AnimationSetFrameAction : public Action { +template class AnimationSetFrameAction final : public Action { public: AnimationSetFrameAction(Animation *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, frame) diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index a3e175be280..49b1100c372 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0; static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1; -class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { +class Anova final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index 093ec55bc63..f971290cdd5 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -39,7 +39,7 @@ enum AmbientLightGain : uint8_t { }; static const uint8_t AMBIENT_LIGHT_GAIN_VALUES[] = {1, 3, 6, 9, 18}; -class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; float get_setup_priority() const override { return setup_priority::BUS; } diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index 2823294207b..bfa64bcc745 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -12,7 +12,7 @@ namespace esphome::apds9960 { -class APDS9960 : public PollingComponent, public i2c::I2CDevice { +class APDS9960 final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(red) SUB_SENSOR(green) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fbc81150917..16b5762f683 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -342,7 +342,7 @@ class APIServer final : public Component, extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition : public Condition { +template class APIConnectedCondition final : public Condition { TEMPLATABLE_VALUE(bool, state_subscription_only) public: bool check(const Ts &...x) override { diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index aef046fbb04..9e0faf98819 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -104,7 +104,7 @@ class ActionResponse { template using ActionResponseCallback = std::function; #endif -template class HomeAssistantServiceCallAction : public Action { +template class HomeAssistantServiceCallAction final : public Action { public: explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { this->flags_.is_event = is_event; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 29eadda927a..ea57d0944bc 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -164,7 +164,8 @@ template class UserServiceTrig // Specialization for NONE - no extra trigger arguments template -class UserServiceTrigger : public UserServiceBase, public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} @@ -175,8 +176,8 @@ class UserServiceTrigger : public UserServ // Specialization for OPTIONAL - call_id and return_response trigger arguments template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} @@ -189,8 +190,8 @@ class UserServiceTrigger : public User // Specialization for ONLY - just call_id trigger argument template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} @@ -201,8 +202,8 @@ class UserServiceTrigger : public UserServ // Specialization for STATUS - just call_id trigger argument (reports success/error without data) template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} @@ -221,7 +222,7 @@ class UserServiceTrigger : public UserSe namespace esphome::api { -template class APIRespondAction : public Action { +template class APIRespondAction final : public Action { public: explicit APIRespondAction(APIServer *parent) : parent_(parent) {} @@ -286,7 +287,7 @@ template class APIRespondAction : public Action { // Action to unregister a service call after execution completes // Automatically appended to the end of action lists for non-none response modes -template class APIUnregisterServiceCallAction : public Action { +template class APIUnregisterServiceCallAction final : public Action { public: explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 2e526ca8252..aa64fa5a4dc 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -6,7 +6,7 @@ namespace esphome::aqi { -class AQISensor : public sensor::Sensor, public Component { +class AQISensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; From 92028e53b5d55ee55608e361d6fa019ab5b8fe30 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:35:12 +1200 Subject: [PATCH 054/343] Mark configurable classes as final (2/21: as3935_i2c-ble_rssi) (#16953) --- esphome/components/as3935_i2c/as3935_i2c.h | 2 +- esphome/components/as3935_spi/as3935_spi.h | 6 ++--- esphome/components/as5600/as5600.h | 2 +- .../components/as5600/sensor/as5600_sensor.h | 2 +- esphome/components/as7341/as7341.h | 2 +- esphome/components/at581x/at581x.h | 2 +- esphome/components/at581x/automation.h | 4 ++-- esphome/components/at581x/switch/rf_switch.h | 2 +- .../atc_mithermometer/atc_mithermometer.h | 2 +- esphome/components/atm90e26/atm90e26.h | 6 ++--- esphome/components/atm90e32/atm90e32.h | 6 ++--- .../atm90e32/button/atm90e32_button.h | 12 +++++----- esphome/components/audio_adc/automation.h | 2 +- esphome/components/audio_dac/automation.h | 6 ++--- .../media_source/audio_file_media_source.h | 4 +++- .../audio_http/audio_http_media_source.h | 4 +++- .../touchscreen/axs15231_touchscreen.h | 2 +- esphome/components/ballu/ballu.h | 2 +- .../components/bang_bang/bang_bang_climate.h | 2 +- esphome/components/bedjet/bedjet_hub.h | 2 +- .../bedjet/climate/bedjet_climate.h | 2 +- esphome/components/bedjet/fan/bedjet_fan.h | 2 +- .../components/bedjet/sensor/bedjet_sensor.h | 2 +- .../beken_spi_led_strip/led_strip.h | 2 +- esphome/components/bh1750/bh1750.h | 2 +- esphome/components/bh1900nux/bh1900nux.h | 2 +- esphome/components/binary/fan/binary_fan.h | 2 +- .../binary/light/binary_light_output.h | 2 +- esphome/components/binary_sensor/automation.h | 20 ++++++++--------- .../binary_sensor_map/binary_sensor_map.h | 2 +- esphome/components/bl0906/bl0906.h | 4 ++-- esphome/components/bl0939/bl0939.h | 2 +- esphome/components/bl0940/bl0940.h | 2 +- .../bl0940/button/calibration_reset_button.h | 2 +- .../bl0940/number/calibration_number.h | 2 +- esphome/components/bl0942/bl0942.h | 2 +- esphome/components/ble_client/automation.h | 22 +++++++++---------- esphome/components/ble_client/ble_client.h | 2 +- .../ble_client/output/ble_binary_output.h | 2 +- .../components/ble_client/sensor/automation.h | 2 +- .../ble_client/sensor/ble_rssi_sensor.h | 2 +- .../components/ble_client/switch/ble_switch.h | 2 +- .../ble_client/text_sensor/automation.h | 2 +- esphome/components/ble_nus/ble_nus.h | 2 +- .../ble_presence/ble_presence_device.h | 6 ++--- esphome/components/ble_rssi/ble_rssi_sensor.h | 2 +- 46 files changed, 86 insertions(+), 82 deletions(-) diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h index c43ec4afd5b..c15f2d6e3e4 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.h +++ b/esphome/components/as3935_i2c/as3935_i2c.h @@ -5,7 +5,7 @@ namespace esphome::as3935_i2c { -class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice { +class I2CAS3935Component final : public as3935::AS3935Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h index 935707a18c0..053e34b3d0e 100644 --- a/esphome/components/as3935_spi/as3935_spi.h +++ b/esphome/components/as3935_spi/as3935_spi.h @@ -8,9 +8,9 @@ namespace esphome::as3935_spi { enum AS3935RegisterMasks { SPI_READ_M = 0x40 }; -class SPIAS3935Component : public as3935::AS3935Component, - public spi::SPIDevice { +class SPIAS3935Component final : public as3935::AS3935Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h index 414633f978b..a385322b70a 100644 --- a/esphome/components/as5600/as5600.h +++ b/esphome/components/as5600/as5600.h @@ -43,7 +43,7 @@ enum AS5600MagnetStatus : uint8_t { MAGNET_WEAK = 6, // 0b110 / magnet too weak }; -class AS5600Component : public Component, public i2c::I2CDevice { +class AS5600Component final : public Component, public i2c::I2CDevice { public: /// Set up the internal sensor array. void setup() override; diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 0086fe54ccd..170ff6d86b8 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -9,7 +9,7 @@ namespace esphome::as5600 { -class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor { +class AS5600Sensor final : public PollingComponent, public Parented, public sensor::Sensor { public: void update() override; void dump_config() override; diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 8bc157fe79a..2d72987f1cd 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -73,7 +73,7 @@ enum AS7341Gain { AS7341_GAIN_512X, }; -class AS7341Component : public PollingComponent, public i2c::I2CDevice { +class AS7341Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index e7f8ee36923..594395e96d8 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -12,7 +12,7 @@ namespace esphome::at581x { -class AT581XComponent : public Component, public i2c::I2CDevice { +class AT581XComponent final : public Component, public i2c::I2CDevice { public: #ifdef USE_SWITCH void set_rf_power_switch(switch_::Switch *s) { diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h index eb8b1b25628..a732d2bcc79 100644 --- a/esphome/components/at581x/automation.h +++ b/esphome/components/at581x/automation.h @@ -7,12 +7,12 @@ namespace esphome::at581x { -template class AT581XResetAction : public Action, public Parented { +template class AT581XResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } }; -template class AT581XSettingsAction : public Action, public Parented { +template class AT581XSettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) TEMPLATABLE_VALUE(int, frequency) diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h index 47367fad45f..0e251b8baaf 100644 --- a/esphome/components/at581x/switch/rf_switch.h +++ b/esphome/components/at581x/switch/rf_switch.h @@ -5,7 +5,7 @@ namespace esphome::at581x { -class RFSwitch : public switch_::Switch, public Parented { +class RFSwitch final : public switch_::Switch, public Parented { protected: void write_state(bool state) override; }; diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc13..3dde5f18680 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index 657f8f3c433..0381d8e5c16 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -6,9 +6,9 @@ namespace esphome::atm90e26 { -class ATM90E26Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E26Component final : public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 5fa224b3535..c636e5065a5 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,9 +13,9 @@ namespace esphome::atm90e32 { -class ATM90E32Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E32Component final : public PollingComponent, + public spi::SPIDevice { public: static const uint8_t PHASEA = 0; static const uint8_t PHASEB = 1; diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h index 0cfce622934..988c6d5c167 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ b/esphome/components/atm90e32/button/atm90e32_button.h @@ -6,7 +6,7 @@ namespace esphome::atm90e32 { -class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32GainCalibrationButton final : public button::Button, public Parented { public: ATM90E32GainCalibrationButton() = default; @@ -14,7 +14,7 @@ class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearGainCalibrationButton() = default; @@ -22,7 +22,7 @@ class ATM90E32ClearGainCalibrationButton : public button::Button, public Parente void press_action() override; }; -class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32OffsetCalibrationButton() = default; @@ -30,7 +30,7 @@ class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearOffsetCalibrationButton() = default; @@ -38,7 +38,7 @@ class ATM90E32ClearOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32PowerOffsetCalibrationButton() = default; @@ -46,7 +46,7 @@ class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearPowerOffsetCalibrationButton() = default; diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h index e74e0232036..fc7af256228 100644 --- a/esphome/components/audio_adc/automation.h +++ b/esphome/components/audio_adc/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_adc { -template class SetMicGainAction : public Action { +template class SetMicGainAction final : public Action { public: explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h index 67bbc78ac21..9c5348271c2 100644 --- a/esphome/components/audio_dac/automation.h +++ b/esphome/components/audio_dac/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_dac { -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -16,7 +16,7 @@ template class MuteOffAction : public Action { AudioDac *audio_dac_; }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -26,7 +26,7 @@ template class MuteOnAction : public Action { AudioDac *audio_dac_; }; -template class SetVolumeAction : public Action { +template class SetVolumeAction final : public Action { public: explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index 2c6189f2727..d269f77c357 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_file { // (the orchestrator calls set_listener() on us with a MediaSourceListener*). // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). -class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioFileMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index e4bd69e9e6f..f794aa1f027 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_http { // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). // The two set_listener() methods live on different base classes and serve opposite directions. -class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioHTTPMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h index 94d232777c6..43bd3799256 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h @@ -7,7 +7,7 @@ namespace esphome::axs15231 { -class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class AXS15231Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h index 8a45d39c703..cb40f415ad2 100644 --- a/esphome/components/ballu/ballu.h +++ b/esphome/components/ballu/ballu.h @@ -10,7 +10,7 @@ namespace esphome::ballu { const float YKR_K_002E_TEMP_MIN = 16.0; const float YKR_K_002E_TEMP_MAX = 32.0; -class BalluClimate : public climate_ir::ClimateIR { +class BalluClimate final : public climate_ir::ClimateIR { public: BalluClimate() : climate_ir::ClimateIR(YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index 1e5ff84883f..d83257f9f34 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -16,7 +16,7 @@ struct BangBangClimateTargetTempConfig { float default_temperature_high{NAN}; }; -class BangBangClimate : public climate::Climate, public Component { +class BangBangClimate final : public climate::Climate, public Component { public: BangBangClimate(); void setup() override; diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h index 9f25f7a4660..32ddd94cff7 100644 --- a/esphome/components/bedjet/bedjet_hub.h +++ b/esphome/components/bedjet/bedjet_hub.h @@ -33,7 +33,7 @@ static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("000 /** * Hub component connecting to the BedJet device over Bluetooth. */ -class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingComponent { +class BedJetHub final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index f59e67eeb7b..6f81b872898 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent { +class BedJetClimate final : public climate::Climate, public BedJetClient, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h index 03f42f1438a..814a87d8b9c 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.h +++ b/esphome/components/bedjet/fan/bedjet_fan.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent { +class BedJetFan final : public fan::Fan, public BedJetClient, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h index 0c3f713579d..c387e9d5fd3 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.h +++ b/esphome/components/bedjet/sensor/bedjet_sensor.h @@ -7,7 +7,7 @@ namespace esphome::bedjet { -class BedjetSensor : public BedJetClient, public Component { +class BedjetSensor final : public BedJetClient, public Component { public: void dump_config() override; diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 4ed640a3bc4..909634e266e 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -19,7 +19,7 @@ enum RGBOrder : uint8_t { ORDER_BRG, }; -class BekenSPILEDStripLightOutput : public light::AddressableLight { +class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/bh1750/bh1750.h b/esphome/components/bh1750/bh1750.h index 39dbd1d6a99..092a21359bc 100644 --- a/esphome/components/bh1750/bh1750.h +++ b/esphome/components/bh1750/bh1750.h @@ -13,7 +13,7 @@ enum BH1750Mode : uint8_t { }; /// This class implements support for the i2c-based BH1750 ambient light sensor. -class BH1750Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1750Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h index 61d1bac268e..f1d62d16472 100644 --- a/esphome/components/bh1900nux/bh1900nux.h +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -6,7 +6,7 @@ namespace esphome::bh1900nux { -class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1900NUXSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h index 17157dd29ca..601f4cb641a 100644 --- a/esphome/components/binary/fan/binary_fan.h +++ b/esphome/components/binary/fan/binary_fan.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryFan : public Component, public fan::Fan { +class BinaryFan final : public Component, public fan::Fan { public: void setup() override; void dump_config() override; diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index f6be7e162e9..32707e8b0c8 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryLightOutput : public light::LightOutput { +class BinaryLightOutput final : public light::LightOutput { public: void set_output(output::BinaryOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1875910affd..d5a85ca9c42 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,7 +18,7 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger : public Trigger<> { +class PressTrigger final : public Trigger<> { public: explicit PressTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -28,7 +28,7 @@ class PressTrigger : public Trigger<> { } }; -class ReleaseTrigger : public Trigger<> { +class ReleaseTrigger final : public Trigger<> { public: explicit ReleaseTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -40,7 +40,7 @@ class ReleaseTrigger : public Trigger<> { bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); -class ClickTrigger : public Trigger<> { +class ClickTrigger final : public Trigger<> { public: explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -61,7 +61,7 @@ class ClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class DoubleClickTrigger : public Trigger<> { +class DoubleClickTrigger final : public Trigger<> { public: explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -127,7 +127,7 @@ class MultiClickTriggerBase : public Trigger<>, public Component { /// Template wrapper that provides inline std::array storage for timing events. /// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger : public MultiClickTriggerBase { +template class MultiClickTrigger final : public MultiClickTriggerBase { public: MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) : MultiClickTriggerBase(parent) { @@ -140,14 +140,14 @@ template class MultiClickTrigger : public MultiClickTriggerBase { std::array timing_storage_{}; }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class StateChangeTrigger : public Trigger, optional > { +class StateChangeTrigger final : public Trigger, optional > { public: explicit StateChangeTrigger(BinarySensor *parent) { parent->add_full_state_callback( @@ -155,7 +155,7 @@ class StateChangeTrigger : public Trigger, optional > { } }; -template class BinarySensorCondition : public Condition { +template class BinarySensorCondition final : public Condition { public: BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -165,7 +165,7 @@ template class BinarySensorCondition : public Condition { bool state_; }; -template class BinarySensorPublishAction : public Action { +template class BinarySensorPublishAction final : public Action { public: explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(bool, state) @@ -179,7 +179,7 @@ template class BinarySensorPublishAction : public Action BinarySensor *sensor_; }; -template class BinarySensorInvalidateAction : public Action { +template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index 60224242db6..bb2c2739574 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -29,7 +29,7 @@ struct BinarySensorMapChannel { * * Each binary sensor has configured parameters that each mapping type uses to compute the single numerical result */ -class BinarySensorMap : public sensor::Sensor, public Component { +class BinarySensorMap final : public sensor::Sensor, public Component { public: void dump_config() override; diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 821aac476c4..54de9f9b0cc 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -53,7 +53,7 @@ class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); -class BL0906 : public PollingComponent, public uart::UARTDevice { +class BL0906 final : public PollingComponent, public uart::UARTDevice { SUB_SENSOR(voltage) SUB_SENSOR(current_1) SUB_SENSOR(current_2) @@ -103,7 +103,7 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction : public Action, public Parented { +template class ResetEnergyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } }; diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index b4f6d42e71f..333bca37152 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -56,7 +56,7 @@ union DataPacket { // NOLINT(altera-struct-pack-align) }; } __attribute__((packed)); -class BL0939 : public PollingComponent, public uart::UARTDevice { +class BL0939 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor_1(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 14cb69d0b09..007fa990d52 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -33,7 +33,7 @@ struct DataPacket { uint8_t checksum; // Packet checksum } __attribute__((packed)); -class BL0940 : public PollingComponent, public uart::UARTDevice { +class BL0940 final : public PollingComponent, public uart::UARTDevice { public: // Sensor setters void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h index d528992d586..f5a4f50886e 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::bl0940 { class BL0940; // Forward declaration of BL0940 class -class CalibrationResetButton : public button::Button, public Component, public Parented { +class CalibrationResetButton final : public button::Button, public Component, public Parented { public: void dump_config() override; diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h index 062890d918e..186a34c5830 100644 --- a/esphome/components/bl0940/number/calibration_number.h +++ b/esphome/components/bl0940/number/calibration_number.h @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -class CalibrationNumber : public number::Number, public Component { +class CalibrationNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index c3668786377..f926dd022d1 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -83,7 +83,7 @@ enum LineFrequency : uint8_t { LINE_FREQUENCY_60HZ = 60, }; -class BL0942 : public PollingComponent, public uart::UARTDevice { +class BL0942 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 01590d1d538..94eeb83b3eb 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -23,7 +23,7 @@ class Automation { }; // implement on_connect automation. -class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -37,7 +37,7 @@ class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { }; // on_disconnect automation -class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -61,7 +61,7 @@ class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +71,7 @@ class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyNotificationTrigger : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger : public Trigger, public BLE } }; -class BLEClientNumericComparisonRequestTrigger : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -94,7 +94,7 @@ class BLEClientNumericComparisonRequestTrigger : public Trigger, publi }; // implement the ble_client.ble_write action. -template class BLEClientWriteAction : public Action, public BLEClientNode { +template class BLEClientWriteAction final : public Action, public BLEClientNode { public: BLEClientWriteAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -231,7 +231,7 @@ template class BLEClientWriteAction : public Action, publ esp_gatt_write_type_t write_type_{}; }; -template class BLEClientPasskeyReplyAction : public Action { +template class BLEClientPasskeyReplyAction final : public Action { public: BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -268,7 +268,7 @@ template class BLEClientPasskeyReplyAction : public Action class BLEClientNumericComparisonReplyAction : public Action { +template class BLEClientNumericComparisonReplyAction final : public Action { public: BLEClientNumericComparisonReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -301,7 +301,7 @@ template class BLEClientNumericComparisonReplyAction : public Ac } value_{.simple = false}; }; -template class BLEClientRemoveBondAction : public Action { +template class BLEClientRemoveBondAction final : public Action { public: BLEClientRemoveBondAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -315,7 +315,7 @@ template class BLEClientRemoveBondAction : public Action BLEClient *parent_{nullptr}; }; -template class BLEClientConnectAction : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -364,7 +364,7 @@ template class BLEClientConnectAction : public Action, pu std::tuple var_{}; }; -template class BLEClientDisconnectAction : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index ca523251ef7..f27bef332b6 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -44,7 +44,7 @@ class BLEClientNode { uint64_t address_; }; -class BLEClient : public BLEClientBase { +class BLEClient final : public BLEClientBase { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 299de9b8605..8ea700529b8 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -11,7 +11,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, public Component { +class BLEBinaryOutput final : public output::BinaryOutput, public BLEClientNode, public Component { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 84430cb7d97..e805ebdb59c 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLESensorNotifyTrigger : public Trigger, public BLESensor { +class BLESensorNotifyTrigger final : public Trigger, public BLESensor { public: explicit BLESensorNotifyTrigger(BLESensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 570a5b423c9..e1590dbdebb 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, public BLEClientNode { +class BLEClientRSSISensor final : public sensor::Sensor, public PollingComponent, public BLEClientNode { public: void loop() override; void update() override; diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9be6d06b1c6..42b450243a5 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientSwitch : public switch_::Switch, public Component, public BLEClientNode { +class BLEClientSwitch final : public switch_::Switch, public Component, public BLEClientNode { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index d4114cd1bae..8a81610668c 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { +class BLETextSensorNotifyTrigger final : public Trigger, public BLETextSensor { public: explicit BLETextSensorNotifyTrigger(BLETextSensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index f1afd54af9e..82e2db69002 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -11,7 +11,7 @@ namespace esphome::ble_nus { -class BLENUS : public uart::UARTComponent, public Component { +class BLENUS final : public uart::UARTComponent, public Component { enum TxStatus { TX_DISABLED, TX_ENABLED, diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index 76e80799485..e17e26ff1c4 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -8,9 +8,9 @@ namespace esphome::ble_presence { -class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index a876fa51d27..8e804ab8e70 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -8,7 +8,7 @@ namespace esphome::ble_rssi { -class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; From 69f905f15448270b842803aaeba562c9e359e79a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 04:18:12 -0500 Subject: [PATCH 055/343] [ci] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1" (#17028) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff846e4b2d..aca6d9007a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@681749ae568c81c2037cb9185e38b709b261bd2f # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: packages: libsdl2-dev version: 1.0 From 1753ccd81198b8a1cdf40374a12c478265c9e5c0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:57:59 -0400 Subject: [PATCH 056/343] [ci] Update component-test CI for ESP-IDF default toolchain (#16383) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .github/actions/cache-esp-idf/action.yml | 14 +- .github/workflows/ci.yml | 89 +++-------- script/determine-jobs.py | 124 +++++++++------ tests/script/test_determine_jobs.py | 187 ++++++++++++++++------- 4 files changed, 247 insertions(+), 167 deletions(-) diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index 7a17c222a39..f566ba4c434 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -2,8 +2,8 @@ name: Cache ESP-IDF description: > Resolve the pinned ESP-IDF version and cache the native ESP-IDF install (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF - natively (clang-tidy for IDF/Arduino and the native-IDF component build) - shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + natively (clang-tidy for IDF/Arduino and the component test batches) shares + one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS defaults to "all", so all toolchains are present regardless of the chip). Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the Python venv already restored. @@ -11,6 +11,12 @@ inputs: framework: description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' default: espidf + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce an ESP-IDF install (e.g. a component batch with + no esp32 target), so a partial/empty install is never written to the key. + default: "false" runs: using: composite steps: @@ -33,13 +39,13 @@ runs: # PRs), and PRs are restore-only -- they never push multi-GB artifacts into # their own scope / the repo quota (e.g. on a version-bump PR). - name: Cache ESP-IDF install (write on dev) - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} - name: Cache ESP-IDF install (restore-only off dev) - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6d9007a8..29d42330cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,8 +270,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} - native-idf: ${{ steps.determine.outputs.native-idf }} - native-idf-components: ${{ steps.determine.outputs.native-idf-components }} + esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} + esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -324,8 +324,8 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT - echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT - echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT + echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT + echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -522,7 +522,6 @@ jobs: key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install - # Shared with the IDF tidy + native-IDF build jobs (same install). if: matrix.cache_idf uses: ./.github/actions/cache-esp-idf with: @@ -592,7 +591,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -673,7 +671,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -758,7 +755,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -805,6 +801,10 @@ jobs: - common - determine-jobs if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 + env: + # esp32 component builds use the native ESP-IDF toolchain (default), so + # share the tidy jobs' install location -- the restore below lands here. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -832,6 +832,12 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install (restore-only) + # A batch may contain no esp32 build, so never save -- just reuse the + # shared install the dev tidy jobs already cached when present. + uses: ./.github/actions/cache-esp-idf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -935,20 +941,19 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi - test-native-idf: - name: Test components with native ESP-IDF + test-esp32-platformio: + name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' env: - ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - # Comma-joined subset of the native-IDF representative component list, - # computed by script/determine-jobs.py (native_idf_components_to_test). + # Comma-joined subset of the esp32 PlatformIO representative component list, + # computed by script/determine-jobs.py (esp32_platformio_components_to_test). # Single source of truth -- the full list lives in - # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. - TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} + # script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -959,66 +964,22 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Prepare build storage on /mnt - # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE - # restoring the cache, so the ~4.5GB restore lands on the roomier volume - # instead of being shadowed by a mount set up later in the run step. - run: | - root_avail=$(df -k / | awk 'NR==2 {print $4}') - mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then - echo "Using /mnt for build files (more space available)" - sudo mkdir -p /mnt/esphome-idf - sudo chown $USER:$USER /mnt/esphome-idf - mkdir -p ~/.esphome-idf - sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - sudo mkdir -p /mnt/test_build_components_build - sudo chown $USER:$USER /mnt/test_build_components_build - mkdir -p tests/test_build_components/build - sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build - else - echo "Using / for build files (more space available than /mnt or /mnt unavailable)" - fi - - - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs (same install); restores - # into the /mnt bind-mount prepared above when present. - uses: ./.github/actions/cache-esp-idf - - - name: Run native ESP-IDF compile test + - name: Run PlatformIO compile test run: | . venv/bin/activate echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation - echo "Disk space before config validation:" - df -h - echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio echo "" echo "Config validation passed! Starting compilation..." echo "" - # Show disk space before compilation - echo "Disk space before compilation:" - df -h - echo "" - # Run compilation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf - - - name: Save ESPHome cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio pre-commit-ci-lite: name: pre-commit.ci lite @@ -1353,7 +1314,7 @@ jobs: - determine-jobs - device-builder - test-build-components-split - - test-native-idf + - test-esp32-platformio - pre-commit-ci-lite - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 4904883ca94..af3e83f96b3 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -466,11 +466,11 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False -# Components tested by the native ESP-IDF compile-test job. This is the +# Components tested by the PlatformIO compile-test job. This is the # single source of truth: the workflow reads the comma-joined list from the -# `native-idf-components` output of `determine-jobs` and uses it as the -# `TEST_COMPONENTS` env on the `test-native-idf` job. -NATIVE_IDF_TEST_COMPONENTS = frozenset( +# `esp32-platformio-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-esp32-platformio` job. +ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( { "esp32", "api", @@ -490,53 +490,75 @@ NATIVE_IDF_TEST_COMPONENTS = frozenset( } ) -# Path prefixes whose changes always trigger the native ESP-IDF compile -# test: anything under esphome/espidf/ (the native IDF runner / API / -# framework / component generator). -NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +# Path prefixes whose changes always trigger the PlatformIO compile test: +# anything under esphome/platformio/ (the PlatformIO runner / toolchain that +# drives every PlatformIO build). The esp32 platform component is already in +# ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal +# component-narrowing path. +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) -# Standalone files that, when changed, also trigger the native ESP-IDF -# compile test: -# - esphome/build_gen/espidf.py -- the native IDF build generator -# (other files under build_gen/ target PlatformIO and don't affect -# the native IDF path) +# Standalone files that, when changed, trigger the PlatformIO compile test: +# - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -NATIVE_IDF_TRIGGER_FILES = frozenset( +ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( { - "esphome/build_gen/espidf.py", + "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) -def _native_idf_path_or_file_trigger(files: list[str]) -> bool: - """Whether any changed file is a native IDF infrastructure / harness trigger.""" +def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" for file in files: - if file in NATIVE_IDF_TRIGGER_FILES: + if file in ESP32_PLATFORMIO_TRIGGER_FILES: return True - if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + if any( + file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ): return True return False -def native_idf_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. +# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator +# affect every esp32 IDF build (now the default toolchain) but aren't +# components, so the component matrix wouldn't otherwise force any esp32 +# compile. When they change we fold the `esp32` component into the matrix so +# the default native-IDF build path is still compiled on an infra-only PR. +ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"}) - The job builds components with the native ESP-IDF toolchain (no - PlatformIO). When only a specific component (or something it depends - on) changed, there's no value in re-building every other unrelated - component in the test list -- the regular ``component-test`` matrix - already covers them via PlatformIO. So we narrow to the intersection - of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + +def _esp_idf_infra_changed(files: list[str]) -> bool: + """Whether any changed file is ESP-IDF build/runner infrastructure.""" + for file in files: + if file in ESP_IDF_INFRA_TRIGGER_FILES: + return True + if any( + file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ): + return True + return False + + +def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP32_PLATFORMIO_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the PlatformIO toolchain. When only a + specific component (or something it depends on) changed, there's no + value in re-building every other unrelated component in the test list -- + the regular ``component-test`` matrix already covers them via the + default toolchain. So we narrow to the intersection of + ``ESP32_PLATFORMIO_TEST_COMPONENTS`` and the changed-component dependency closure. Returns the full list (sorted) when we can't safely narrow: 1. Core C++/Python files changed (``esphome/core/*``). - 2. Native IDF infrastructure changed (``esphome/espidf/*`` or - ``esphome/build_gen/espidf.py``). + 2. PlatformIO infrastructure changed (``esphome/platformio/*`` or + ``esphome/build_gen/platformio.py``). 3. The test harness or workflow itself changed (``script/test_build_components.py``, ``.github/workflows/ci.yml``). @@ -558,31 +580,31 @@ def native_idf_components_to_test(branch: str | None = None) -> list[str]: """ files = changed_files(branch) - if core_changed(files) or _native_idf_path_or_file_trigger(files): - return sorted(NATIVE_IDF_TEST_COMPONENTS) + if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) component_files = [f for f in files if filter_component_and_test_files(f)] changed = get_components_with_dependencies(component_files, True) - return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) -def should_run_native_idf(branch: str | None = None) -> bool: - """Determine if the `test-native-idf` compile-test job should run. +def should_run_esp32_platformio(branch: str | None = None) -> bool: + """Determine if the `test-esp32-platformio` compile-test job should run. - Runs whenever ``native_idf_components_to_test()`` returns a non-empty + Runs whenever ``esp32_platformio_components_to_test()`` returns a non-empty list. Skipping the job on unrelated Python-only PRs avoids ~5 min of CI per PR (worse on cold caches). The regular ``component-test`` - matrix still exercises the same components through PlatformIO when - those components change. + matrix still exercises the same components through the default + toolchain when those components change. Args: branch: Branch to compare against. If None, uses default. Returns: - True if the native ESP-IDF compile test should run, False otherwise. + True if the PlatformIO compile test should run, False otherwise. """ - return bool(native_idf_components_to_test(branch)) + return bool(esp32_platformio_components_to_test(branch)) def determine_cpp_unit_tests( @@ -1162,8 +1184,8 @@ def main() -> None: run_python_linters = True run_import_time = True run_device_builder = True - native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) - run_native_idf = True + esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + run_esp32_platformio = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1173,8 +1195,8 @@ def main() -> None: run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) + esp32_platformio_components = esp32_platformio_components_to_test(args.branch) + run_esp32_platformio = bool(esp32_platformio_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1228,6 +1250,18 @@ def main() -> None: if _component_has_tests(component) ] + # ESP-IDF build-gen/runner changed but no component pulled esp32 in: fold the + # `esp32` component into the matrix so the default native-IDF build path is + # still compiled on an infra-only PR. force_all/core already test everything, + # so skip there. Runs grouped (not added to directly-changed). + if ( + not is_core_change + and _esp_idf_infra_changed(changed) + and "esp32" not in changed_components_with_tests + and _component_has_tests("esp32") + ): + changed_components_with_tests.append("esp32") + # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation # (pin conflicts, etc.) since they contain the actual changes being reviewed @@ -1345,8 +1379,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, - "native_idf": run_native_idf, - "native_idf_components": ",".join(native_idf_components), + "esp32_platformio": run_esp32_platformio, + "esp32_platformio_components": ",".join(esp32_platformio_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index f8f359ee22b..a9876632bd9 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -68,13 +68,13 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: @pytest.fixture -def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: - """Mock native_idf_components_to_test from determine_jobs. +def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: + """Mock esp32_platformio_components_to_test from determine_jobs. - main() drives both the ``native_idf`` boolean output and the - ``native_idf_components`` CSV from this one function. + main() drives both the ``esp32_platformio`` boolean output and the + ``esp32_platformio_components`` CSV from this one function. """ - with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + with patch.object(determine_jobs, "esp32_platformio_components_to_test") as mock: yield mock @@ -115,7 +115,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -131,7 +131,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["api", "esp32"] + mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -213,8 +213,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "api,esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "api,esp32" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -248,7 +248,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -264,7 +264,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -305,8 +305,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False - assert output["native_idf"] is False - assert output["native_idf_components"] == "" + assert output["esp32_platformio"] is False + assert output["esp32_platformio_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -322,6 +322,65 @@ def test_main_no_tests_should_run( assert output["component_test_batches"] == [] +def test_main_esp_idf_infra_change_folds_esp32( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An ESP-IDF infra-only change folds the `esp32` component into the matrix, + so the default native-IDF build path is still compiled.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + + # IDF build generator changed; no component changed. + mock_changed_files.return_value = ["esphome/build_gen/espidf.py"] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + # esp32 has tests on disk, but pin it so the fold-in isn't coupled to layout. + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + # Only `esp32` is folded in (not the whole representative set), and it's + # grouped, not isolated (infra changed, not the component). + assert output["changed_components_with_tests"] == ["esp32"] + assert output["directly_changed_components_with_tests"] == [] + assert output["component_test_count"] == 1 + + def test_main_with_branch_argument( mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, @@ -329,7 +388,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -345,7 +404,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["esp32"] + mock_esp32_platformio_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -384,7 +443,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") - mock_native_idf_components_to_test.assert_called_once_with("main") + mock_esp32_platformio_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -398,8 +457,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "esp32" assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -916,23 +975,22 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() -_NATIVE_IDF_FULL_LIST_FILES = [ +_ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Core C++/Python changes -- caught by core_changed() ["esphome/core/component.cpp"], ["esphome/core/config.py"], - # Native IDF infrastructure paths - ["esphome/espidf/framework.py"], - ["esphome/espidf/component.py"], - ["esphome/espidf/api.py"], - ["esphome/build_gen/espidf.py"], + # PlatformIO subsystem (path-prefix trigger) + build generator + ["esphome/platformio/runner.py"], + ["esphome/platformio/toolchain.py"], + ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], ] -@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) -def test_native_idf_components_to_test_returns_full_list_on_infrastructure( +@pytest.mark.parametrize("changed_files", _ESP32_PLATFORMIO_FULL_LIST_FILES) +def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure( changed_files: list[str], ) -> None: """Infrastructure / core / harness changes fall back to the full component list.""" @@ -944,8 +1002,8 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( determine_jobs, "get_components_with_dependencies", return_value=["wifi"] ), ): - result = determine_jobs.native_idf_components_to_test() - assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + result = determine_jobs.esp32_platformio_components_to_test() + assert result == sorted(determine_jobs.ESP32_PLATFORMIO_TEST_COMPONENTS) @pytest.mark.parametrize( @@ -965,7 +1023,7 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], ), # api in the test set -- narrow to [api] even though the closure - # has other (unrelated to native-IDF coverage) entries. + # has other (unrelated to PlatformIO coverage) entries. ( ["esphome/components/api/api_connection.cpp"], ["api", "logger"], @@ -979,15 +1037,15 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ), # Pure Python-only change outside trigger paths -> empty. (["esphome/yaml_util.py"], [], []), - # Non-IDF files in esphome/build_gen/ do NOT trigger the full - # list -- only esphome/build_gen/espidf.py is a trigger. - (["esphome/build_gen/platformio.py"], [], []), + # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the + # full list -- only esphome/build_gen/platformio.py is a trigger. + (["esphome/build_gen/espidf.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), ], ) -def test_native_idf_components_to_test_narrowing( +def test_esp32_platformio_components_to_test_narrowing( changed_files: list[str], dependency_closure: list[str], expected: list[str], @@ -1001,12 +1059,12 @@ def test_native_idf_components_to_test_narrowing( return_value=dependency_closure, ), ): - result = determine_jobs.native_idf_components_to_test() + result = determine_jobs.esp32_platformio_components_to_test() assert result == expected -def test_native_idf_components_to_test_with_branch() -> None: - """native_idf_components_to_test passes branch argument through. +def test_esp32_platformio_components_to_test_with_branch() -> None: + """esp32_platformio_components_to_test passes branch argument through. Regression test: an earlier version called ``get_changed_components()``, which silently ignored the branch argument because that helper re-runs @@ -1021,7 +1079,7 @@ def test_native_idf_components_to_test_with_branch() -> None: ), ): mock_changed.return_value = [] - determine_jobs.native_idf_components_to_test("release") + determine_jobs.esp32_platformio_components_to_test("release") mock_changed.assert_called_once_with("release") @@ -1033,25 +1091,46 @@ def test_native_idf_components_to_test_with_branch() -> None: (["esp32", "api"], True), ], ) -def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: - """should_run_native_idf is a thin wrapper around the component list.""" +def test_should_run_esp32_platformio( + components_to_test: list[str], expected: bool +) -> None: + """should_run_esp32_platformio is a thin wrapper around the component list.""" with patch.object( determine_jobs, - "native_idf_components_to_test", + "esp32_platformio_components_to_test", return_value=components_to_test, ): - assert determine_jobs.should_run_native_idf() is expected + assert determine_jobs.should_run_esp32_platformio() is expected -def test_should_run_native_idf_with_branch() -> None: - """Test should_run_native_idf passes branch argument through.""" +def test_should_run_esp32_platformio_with_branch() -> None: + """Test should_run_esp32_platformio passes branch argument through.""" with patch.object( - determine_jobs, "native_idf_components_to_test", return_value=[] + determine_jobs, "esp32_platformio_components_to_test", return_value=[] ) as mock_inner: - determine_jobs.should_run_native_idf("release") + determine_jobs.should_run_esp32_platformio("release") mock_inner.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected"), + [ + # ESP-IDF runner / framework / build generator -> trigger + (["esphome/espidf/runner.py"], True), + (["esphome/espidf/framework.py"], True), + (["esphome/build_gen/espidf.py"], True), + # PlatformIO build gen and esp32 component are NOT IDF-infra triggers + (["esphome/build_gen/platformio.py"], False), + (["esphome/components/esp32/__init__.py"], False), + (["README.md"], False), + ([], False), + ], +) +def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: + """ESP-IDF build/runner infra paths are detected; other paths are not.""" + assert determine_jobs._esp_idf_infra_changed(changed_files) is expected + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ @@ -2751,7 +2830,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2772,7 +2851,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2813,9 +2892,9 @@ def test_main_force_all_overrides_detection( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS - assert "esp32" in output["native_idf_components"].split(",") + assert output["esp32_platformio"] is True + # esp32_platformio_components is a CSV of ESP32_PLATFORMIO_TEST_COMPONENTS + assert "esp32" in output["esp32_platformio_components"].split(",") assert output["cpp_unit_tests_run_all"] is True assert output["cpp_unit_tests_components"] == [] assert output["benchmarks"] is True @@ -2826,7 +2905,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.assert_not_called() mock_should_run_import_time.assert_not_called() mock_should_run_device_builder.assert_not_called() - mock_native_idf_components_to_test.assert_not_called() + mock_esp32_platformio_components_to_test.assert_not_called() mock_determine_cpp_unit_tests.assert_not_called() # Component matrix is populated from disk (tests/components/ in the repo) assert output["component_test_count"] > 0 @@ -2840,7 +2919,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2855,7 +2934,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2886,7 +2965,7 @@ def test_main_force_all_off_uses_detection( assert output["clang_tidy"] is False assert output["clang_format"] is False assert output["python_linters"] is False - assert output["native_idf"] is False + assert output["esp32_platformio"] is False assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() From bf12af46458c023a372d76f07800426550b702c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 18 Jun 2026 09:31:49 -0400 Subject: [PATCH 057/343] [wifi] Add runtime suppression of post-connect roaming scans (#17012) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 16 ++++++ esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 61 ++++++++++++++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp32-idf.yaml | 6 ++- 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 080a7bb97ba..1cfd2b9821a 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -764,6 +764,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" @@ -794,6 +795,19 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True +def enable_runtime_roaming_suppression() -> None: + """Enable runtime suppression of post-connect roaming scans. + + Components that are disrupted by the radio briefly going off-channel during a + roaming scan (e.g., audio playback) should call this function during their code + generation. This enables the request_roaming_suppression() and + release_roaming_suppression() APIs, which pause periodic roaming scans while active. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_ROAMING_SUPPRESSION_KEY] = True + + def request_wifi_ip_state_listener() -> None: """Request an IP state listener slot.""" CORE.data[IP_STATE_LISTENERS_KEY] = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) + 1 @@ -827,6 +841,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 07cb2ac2436..ffc6ea8e144 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -822,7 +822,7 @@ void WiFiComponent::loop() { } // else: scan in progress, wait } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && - now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) { + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { this->check_roaming_(now); } } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d0521e548a1..c774e3a68ef 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -16,6 +16,8 @@ #endif #include "esphome/core/string_ref.h" +#include +#include #include #include #include @@ -604,6 +606,49 @@ class WiFiComponent final : public Component { bool release_high_performance(); #endif // USE_WIFI_RUNTIME_POWER_SAVE +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + /** Request that post-connect roaming scans be suppressed. + * + * Components that are disrupted by the radio briefly going off-channel during a + * scan (e.g., audio playback) can call this to pause periodic roaming scans while + * active. Multiple components can request suppression simultaneously; roaming + * resumes once every requester has called release_roaming_suppression(). + * + * A roaming scan already in progress is allowed to finish; this only prevents new + * roaming scans from starting. The roaming interval timer is not reset, so roaming + * resumes on the next loop once suppression is released (and the interval elapsed). + * + * Note: Only supported on ESP32. + * + * Thread-safe: may be called from any task. + */ + void request_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: saturate at max instead of wrapping, so an excess of requests can't roll the + // counter back to zero and unintentionally re-enable roaming. + while (current < std::numeric_limits::max() && + !this->roaming_suppression_count_.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) { + } + } + + /** Release a roaming suppression request. + * + * Must be paired with a prior request_roaming_suppression() call. When all requests + * are released (count reaches zero), post-connect roaming resumes. A release with no + * outstanding request is ignored rather than underflowing the counter. + * + * Thread-safe: may be called from any task. + */ + void release_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: decrement only if non-zero, so an unmatched release can't wrap the counter + // and permanently suppress roaming. + while (current > 0 && + !this->roaming_suppression_count_.compare_exchange_weak(current, current - 1, std::memory_order_relaxed)) { + } + } +#endif // USE_ESP32 && USE_WIFI_RUNTIME_ROAMING_SUPPRESSION + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); @@ -732,6 +777,15 @@ class WiFiComponent final : public Component { void process_roaming_scan_(); void clear_roaming_state_(); + /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). + bool roaming_suppressed_() const { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + return this->roaming_suppression_count_.load(std::memory_order_relaxed) != 0; +#else + return false; +#endif + } + /// Free scan results memory unless a component needs them void release_scan_results_(); @@ -845,6 +899,13 @@ class WiFiComponent final : public Component { // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; uint8_t roaming_attempts_{0}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // Count of active roaming-suppression requests. Incremented/decremented from any task + // (e.g. audio playback), read in loop(). Roaming scans are paused while non-zero. + // Relaxed ordering is sufficient: the count value is the only data shared across threads, + // so no happens-before relationship with other memory needs to be established. + std::atomic roaming_suppression_count_{0}; +#endif #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 410858f904d..17b5e648622 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -312,6 +312,7 @@ #define ESPHOME_WIFI_CONNECT_STATE_LISTENERS 2 #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE +#define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index b2b2233ef32..d000c611709 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,15 +1,19 @@ psram: -# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +# Tests the high performance and roaming suppression request/release APIs; +# requires the USE_WIFI_RUNTIME_POWER_SAVE and USE_WIFI_RUNTIME_ROAMING_SUPPRESSION defines esphome: platformio_options: build_flags: - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + - "-DUSE_WIFI_RUNTIME_ROAMING_SUPPRESSION" on_boot: - then: - lambda: |- esphome::wifi::global_wifi_component->request_high_performance(); esphome::wifi::global_wifi_component->release_high_performance(); + esphome::wifi::global_wifi_component->request_roaming_suppression(); + esphome::wifi::global_wifi_component->release_roaming_suppression(); wifi: use_psram: true From 14e89f3dae752e968d979b40f437ed2814ee3615 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:31:17 +0000 Subject: [PATCH 058/343] Bump actions/checkout from 6.0.3 to 7.0.0 (#17049) Signed-off-by: dependabot[bot] --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 6 +-- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 42 +++++++++---------- .../codeowner-approved-label-update.yml | 2 +- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 8 ++-- .github/workflows/sync-device-classes.yml | 4 +- 12 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index e48d6f69bd2..d034227ef6d 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c6e9a358ab2..2155b67b25d 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 373cd905b19..8301f8e9e37 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -61,7 +61,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -145,7 +145,7 @@ jobs: - "ha-addon" - "docker" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -202,7 +202,7 @@ jobs: - nrf52 - host steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 43d530128cc..3313ced690b 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 35cfce65f80..4bef082aab0 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29d42330cda..e46c6e2fc51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -124,7 +124,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -152,11 +152,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: esphome/device-builder ref: main @@ -225,7 +225,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -285,7 +285,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -357,7 +357,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -409,7 +409,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -438,7 +438,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -496,7 +496,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -579,7 +579,7 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -659,7 +659,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -743,7 +743,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -826,7 +826,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -956,7 +956,7 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -990,7 +990,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1016,7 +1016,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.base_ref }} @@ -1198,7 +1198,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1267,7 +1267,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 1bd60fd11d8..9b1333734e9 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 5ad0b02de16..da9c5f63d64 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e559472b60c..5a448c40031 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 0e2efb1bcf5..2bb6505b74e 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8efc395951a..3056d9e7d6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ab1ce2b5874..05036f3500f 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Checkout Home Assistant - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: home-assistant/core path: lib/home-assistant From a39505f5ef0426fe21977a9cc8c7e9a7dad3d983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:33:01 +0000 Subject: [PATCH 059/343] Bump CodSpeedHQ/action from 4.17.5 to 4.17.6 (#17047) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46c6e2fc51..6774695e582 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 + uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 with: run: | . venv/bin/activate From 1a553018bfa8a8e84da002a136643590e1c135eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:57 -0400 Subject: [PATCH 060/343] [build] Skip target-platform deps when populating host unit-test config (#17039) --- script/build_helpers.py | 21 ++++++--- tests/script/test_build_helpers.py | 76 ++++++++++++++++++++++++++++++ tests/script/test_test_helpers.py | 2 + 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/script/test_build_helpers.py diff --git a/script/build_helpers.py b/script/build_helpers.py index eaf3a1f1a7c..50830c221ea 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -70,12 +70,15 @@ def populate_dependency_config( * ``domain.platform`` form (e.g. ``sensor.gpio``) appends ``{platform: }`` to ``config[domain]``, creating the list if needed. - * Bare components are looked up via ``get_component_fn``. Platform - components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are - initialised as ``[]`` so the sibling ``domain.platform`` branch can - ``append`` into them. Everything else is populated by running the - component's schema with ``{}`` so defaults exist; if the schema requires - explicit input, an empty ``{}`` is used as a fallback. + * Bare components are looked up via ``get_component_fn``. Target-platform + components (``is_target_platform``, e.g. ``esp32``) are skipped entirely: + a host build targets ``host``, so a foreign target platform's sources are + guarded out and its schema must not run here (it would mutate global CORE + state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``) + and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling + ``domain.platform`` branch can ``append`` into them. Everything else is + populated by running the component's schema with ``{}`` so defaults exist; + if the schema requires explicit input, an empty ``{}`` is used as a fallback. Platform components must always be a list here even when no ``domain.platform`` entry follows, because the ``domain.platform`` branch @@ -96,6 +99,12 @@ def populate_dependency_config( component = get_component_fn(component_name) if component is None: continue + # Skip target platforms (e.g. esp32): a host build targets `host`, so a + # foreign target's sources are guarded out, and running its schema with + # {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF), + # crashing the host compile. See #17035. + if component.is_target_platform: + continue if component.multi_conf or component.is_platform_component: config.setdefault(component_name, []) elif component_name not in config: diff --git a/tests/script/test_build_helpers.py b/tests/script/test_build_helpers.py new file mode 100644 index 00000000000..efa6a754839 --- /dev/null +++ b/tests/script/test_build_helpers.py @@ -0,0 +1,76 @@ +"""Unit tests for script/build_helpers.py.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import build_helpers. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import build_helpers # noqa: E402 + +from esphome.core import CORE # noqa: E402 + + +class _FakeComponent: + def __init__(self, config_schema, *, is_target_platform=False): + self.multi_conf = False + self.is_platform_component = False + self.is_target_platform = is_target_platform + self.config_schema = config_schema + + +@pytest.fixture(autouse=True) +def _restore_core_toolchain(): + """Keep CORE.toolchain changes from leaking between tests.""" + saved = CORE.toolchain + try: + yield + finally: + CORE.toolchain = saved + + +def test_populate_dependency_config_skips_target_platforms() -> None: + """Target-platform deps must be skipped, not config-populated, in a host build. + + Regression test for #17035: esp32 (a target platform) appears only as a + transitive dependency of a host C++ unit test. Running its schema with {} + set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation, + which crashed the host compile with KeyError('esp32'). The fix skips + target-platform components entirely so their schema never runs. + """ + CORE.toolchain = None # the state a host build starts from + schema_calls = [] + + def leaky_schema(value): + # If this ever runs for a target platform, the bug is back. + schema_calls.append(value) + CORE.toolchain = "esp-idf-leak" + raise ValueError("no board or variant") + + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["esp32"], + get_component_fn=lambda name: _FakeComponent( + leaky_schema, is_target_platform=True + ), + register_platform_fn=lambda domain: None, + ) + + assert "esp32" not in config # skipped: no synthesized entry + assert schema_calls == [] # schema never run + assert CORE.toolchain is None # no global side effect leaked + + +def test_populate_dependency_config_populates_defaults() -> None: + """A non-target-platform dep still has its schema defaults harvested.""" + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["ok"], + get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}), + register_platform_fn=lambda domain: None, + ) + assert config["ok"] == {"default": 1} diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index a8100252da1..4b05cab3767 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -266,11 +266,13 @@ def _make_component_stub( *, multi_conf: bool = False, is_platform_component: bool = False, + is_target_platform: bool = False, config_schema=None, ) -> MagicMock: stub = MagicMock() stub.multi_conf = multi_conf stub.is_platform_component = is_platform_component + stub.is_target_platform = is_target_platform stub.config_schema = config_schema return stub From 19cca9e177045dd95f86cc351a25c7d5a4fc89b1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:41:03 -0400 Subject: [PATCH 061/343] [esp32] Remove framework migration notice (#17023) --- esphome/components/esp32/__init__.py | 53 ---------------------------- 1 file changed, 53 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aee86a0554e..ec33d9d271e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1589,65 +1589,12 @@ FRAMEWORK_SCHEMA = cv.Schema( ) -# Remove this class in 2026.7.0 -class _FrameworkMigrationWarning: - shown = False - - -def _show_framework_migration_message(name: str, variant: str) -> None: - """Show a message about the framework default change and how to switch back to Arduino.""" - # Remove this function in 2026.7.0 - if _FrameworkMigrationWarning.shown: - return - _FrameworkMigrationWarning.shown = True - - from esphome.log import AnsiFore, color - - message = ( - color( - AnsiFore.BOLD_CYAN, - f"💡 NOTICE: {name} does not have a framework specified.", - ) - + "\n\n" - + f"Starting with ESPHome 2026.1.0, the default framework for {variant} is ESP-IDF.\n" - + "(We've been warning about this change since ESPHome 2025.8.0)\n" - + "\n" - + "Why we made this change:\n" - + color(AnsiFore.GREEN, " ✨ Smaller firmware binaries\n") - + color(AnsiFore.GREEN, " ⚡ Faster compile times\n") - + color(AnsiFore.GREEN, " 🚀 Better performance and newer features\n") - + color(AnsiFore.GREEN, " 🔧 More actively maintained by ESPHome\n") - + "\n" - + "To continue using Arduino, add this to your YAML under 'esp32:':\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: arduino\n") - + "\n" - + "To silence this message with ESP-IDF, explicitly set:\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: esp-idf\n") - + "\n" - + "Migration guide: " - + color( - AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf/", - ) - ) - _LOGGER.warning(message) - - def _set_default_framework(config): config = config.copy() if CONF_FRAMEWORK not in config: config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) if CONF_TYPE not in config[CONF_FRAMEWORK]: - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF - # Show migration message for variants that previously defaulted to Arduino - # Remove this message in 2026.7.0 - if variant in ARDUINO_ALLOWED_VARIANTS: - _show_framework_migration_message( - config.get(CONF_NAME, "This device"), variant - ) return config From f6c78f74154d8328b163bb053d170ebc7349924b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:43:17 -0400 Subject: [PATCH 062/343] [uptime] Revert timestamp sensor device_class to timestamp (#17037) --- esphome/components/uptime/sensor/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 6ce0795cdb7..e2a7aee1a2f 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_UPTIME, + DEVICE_CLASS_TIMESTAMP, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, + icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_UPTIME, + device_class=DEVICE_CLASS_TIMESTAMP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( From 53e85e07d475abab906f6597e9d1b5a7c958dc84 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:21 -0400 Subject: [PATCH 063/343] [esp32] Support `esphome idedata` with the native ESP-IDF toolchain (#17040) --- esphome/__main__.py | 15 ++++++++++++ esphome/espidf/toolchain.py | 1 + tests/unit_tests/test_espidf_toolchain.py | 28 +++++++++++++++++++---- tests/unit_tests/test_main.py | 26 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27dd878495d..bda3dcbd05a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1771,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index c622a2dd365..000ce739dbd 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -472,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index b2309439f98..017d8c49b4f 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e44f746a750..acd39cedc62 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -6257,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true( mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 From a0f546e375ae2c74c34b4ce5b25dd5e520c5ec14 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:22 -0400 Subject: [PATCH 064/343] [ci] Smoke-test Arduino framework in esp32 PlatformIO job (#17034) --- .github/workflows/ci.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6774695e582..10ace8c179e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -971,16 +971,17 @@ jobs: echo "Testing components: $TEST_COMPONENTS" echo "" - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio - - echo "" - echo "Config validation passed! Starting compilation..." - echo "" - - # Run compilation (auto-grouped by test_build_components.py) + # compile validates config first, so a separate config pass is + # redundant for this smoke test. ESP-IDF framework via PlatformIO: python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio + echo "" + echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." + echo "" + + # Arduino framework via PlatformIO (only components with an esp32-ard test are built): + python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + pre-commit-ci-lite: name: pre-commit.ci lite runs-on: ubuntu-latest From 8e7518fe9df898e487f15a570c55c1c09f8cd12e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:15:38 -0400 Subject: [PATCH 065/343] [esp32] Don't overwrite PlatformIO's factory.bin (#17042) --- esphome/components/esp32/post_build.py.script | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b9..f1a38f9e76f 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, From b97182d302ef98da48fc26eb10149bf3d5b1b853 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jun 2026 16:16:14 -0500 Subject: [PATCH 066/343] [logger] Hold recursion guard while draining the task log buffer (#17044) --- esphome/components/logger/logger.cpp | 4 + .../logger_buffered_recursion_guard.yaml | 61 +++++++++ .../test_logger_buffered_recursion_guard.py | 119 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/integration/fixtures/logger_buffered_recursion_guard.yaml create mode 100644 tests/integration/test_logger_buffered_recursion_guard.py diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101d..684da0202e4 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 00000000000..058adbff990 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 00000000000..5bef915b284 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + api_messages: list[str] = [] + all_drained = asyncio.Event() + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. + def on_log(msg) -> None: + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # Wait until every buffered thread message has been delivered over the API. + try: + await asyncio.wait_for(all_drained.wait(), timeout=30.0) + except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + pytest.fail( + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." + ) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) From a497174da24cd864cade284a17e89c9732479ba6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:05:20 +1200 Subject: [PATCH 067/343] Bump bundled esphome-device-builder to 1.0.10 (#17051) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18a99037351..221121c8d38 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 RUN \ platformio settings set enable_telemetry No \ From 1dbd9af6179bcae2c758758c5d7d70b2965ee06d Mon Sep 17 00:00:00 2001 From: Big Mike Date: Fri, 19 Jun 2026 00:04:11 -0500 Subject: [PATCH 068/343] [sen6x] Remove codeowner (#17056) --- CODEOWNERS | 2 +- esphome/components/sen6x/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3265627c030..d425614582c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -445,7 +445,7 @@ esphome/components/select/* @esphome/core esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras -esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 19c0cb500e4..5eb34add65c 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, ) -CODEOWNERS = ["@martgras", "@mebner86", "@mikelawrence", "@tuct"] +CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] From 6a79dfb5c5a954cf3d0ba1da2cfd3351e165d3f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:07:32 +1200 Subject: [PATCH 069/343] Bump ruff from 0.15.17 to 0.15.18 (#17046) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fc9681921a6..b0e917566e6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.17 # also change in .pre-commit-config.yaml when updating +ruff==0.15.18 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 4ae6dc355f1e525f0ebe1e2cd8d2965588ec4b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jun 2026 00:08:07 -0500 Subject: [PATCH 070/343] [select] Remove deprecated state member (#17027) --- esphome/components/select/select.cpp | 4 ---- esphome/components/select/select.h | 7 ------- .../fixtures/multi_device_preferences.yaml | 12 ++++++------ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 7c3dab15ad5..17c6c811dd8 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -27,10 +27,6 @@ void Select::publish_state(size_t index) { const char *option = this->option_at(index); this->set_has_state(true); this->active_index_ = index; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->state = option; // Update deprecated member for backward compatibility -#pragma GCC diagnostic pop ESP_LOGV(TAG, "'%s' >> %s (%zu)", this->get_name().c_str(), option, index); this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 465283d92a2..34d92485234 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -30,15 +30,8 @@ class Select : public EntityBase { public: SelectTraits traits; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use current_option() instead. This member will be removed in ESPHome 2026.7.0. - ESPDEPRECATED("Use current_option() instead of .state. Will be removed in 2026.7.0", "2026.1.0") - std::string state{}; - Select() = default; ~Select() = default; -#pragma GCC diagnostic pop void publish_state(const std::string &state); void publish_state(const char *state); diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 634d7157b2a..01e4394559b 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -109,7 +109,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); - id(mode_device_a).state = x; + id(mode_device_a).publish_state(x); - platform: template name: Mode @@ -124,7 +124,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); - id(mode_device_b).state = x; + id(mode_device_b).publish_state(x); - platform: template name: Mode @@ -138,7 +138,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Main Mode set to %s", x.c_str()); - id(mode_main).state = x; + id(mode_main).publish_state(x); # Button to trigger preference logging test button: @@ -153,9 +153,9 @@ button: ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); - ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); - ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); - ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); // Log preference hashes for entities that actually store preferences ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); From 350e7bb7638b0e8551d1e5f1244bbe62e952cb77 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:17:06 -0400 Subject: [PATCH 071/343] [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) --- esphome/espidf/framework.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c0e9a0051f9..6f4aeef9f07 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() - return CORE.data_dir / "idf" + path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + else: + path = CORE.data_dir / "idf" + # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) + # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which + # otherwise warns that the venv interpreter path doesn't match the install. + return path.resolve() # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply From 50994704a39397db0672f0fb16cdaa5fd4366c7c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:17 -0400 Subject: [PATCH 072/343] [fastled_base] Fix RMT5 intr_priority conflict (#17072) --- esphome/components/fastled_base/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index d99dffdc081..a26a235da73 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -50,6 +50,11 @@ async def new_fastled_light(config): ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", ) cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") else: cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) From f57d31374e1cc88bdd5f16e16fface70ef9184fb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:19:30 -0400 Subject: [PATCH 073/343] [packet_transport] Mark encryption key as cv.sensitive (#17066) --- esphome/components/packet_transport/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 0b166bb65c2..4293dffb151 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = { cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value( cv.Schema( { - cv.Required(CONF_KEY): cv.string, + cv.Required(CONF_KEY): cv.sensitive(cv.string), } ), key=CONF_KEY, From db6bd36cf90eccbec52cba89feddecb6178ca14f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:17 -0400 Subject: [PATCH 074/343] Bump py7zr from 1.1.0 to 1.1.3 (#17071) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index efb5ec8723a..717f3b7e210 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==1.1.0 +py7zr==1.1.3 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From d8bd80ef3888b9c30b0a5690c2b6c4ec018bb71a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:28 -0400 Subject: [PATCH 075/343] Bump resvg-py from 0.3.2 to 0.3.3 (#17070) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 717f3b7e210..06a383b00aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.2 +resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From 3fb250133fe12af47c1b9d8d7b6e4e92363d4e0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:20:37 -0400 Subject: [PATCH 076/343] Bump pytest from 9.1.0 to 9.1.1 (#17069) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0e917566e6..4e498abc21e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.1.0 +pytest==9.1.1 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 From 657d9bf4d094e13b7c7e5d3c7ff67566dfa53a8b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:30:30 -0500 Subject: [PATCH 077/343] Bump bundled esphome-device-builder to 1.0.11 (#17081) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 221121c8d38..aa0406320c1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 RUN \ platformio settings set enable_telemetry No \ From d77c0d2bc544a9c94e4e317ce485c59081f48b5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:33:54 -0500 Subject: [PATCH 078/343] [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) --- .../etc/s6-overlay/s6-rc.d/esphome/run | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index a61f237a5a1..d4628ffa832 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then rm -rf /config/esphome/.esphome fi +# Only signal device-builder to expose the public LAN port when the operator +# mapped port 6052, matching the legacy dashboard where nginx listened on the +# fixed port 6052 only when it was configured. We use the mapping purely as a +# presence check and don't forward the published value; device-builder binds +# its default port 6052 (the fixed container port, as the legacy +# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth +# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are +# required to bind 6052 unauthenticated; either alone stays ingress-only. +set -- +if bashio::var.has_value "$(bashio::addon.port 6052)"; then + set -- --ha-addon-allow-public +fi + bashio::log.info "Starting ESPHome Device Builder..." exec esphome-device-builder /config/esphome \ --ha-addon \ - --ingress-port "$(bashio::addon.ingress_port)" + --ingress-port "$(bashio::addon.ingress_port)" \ + "$@" From 59711b8e6a39bb5db7b8b79067298feb0a268a77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jun 2026 11:51:42 -0500 Subject: [PATCH 079/343] Add THREAT_MODEL.md (#17089) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- THREAT_MODEL.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 THREAT_MODEL.md diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 00000000000..a4640467c98 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,104 @@ +# ESPHome Threat Model + +This document defines the trust boundary for the **ESPHome** repository — the +Python compiler/CLI and the device firmware it generates — so that real security +bugs can be told apart from defense-in-depth improvements. It gives contributors, +reviewers, and security researchers a clear answer to one question: +**does this issue let an _unauthenticated_ attacker do something they shouldn't?** + +Related documents: + +- Deployment guidance for operators: + https://esphome.io/guides/security_best_practices/ +- The **Device Builder dashboard** (the web UI, its authentication, ingress, + Origin/Host gates, and peer-link pairing) lives in a separate repository and + has its own threat model. If your report concerns any of that, please read and + report there instead: + https://github.com/esphome/device-builder/blob/main/docs/THREAT_MODEL.md + +## The trust boundary + +For this repository there are two trusted inputs by design: + +1. **The configuration.** Anyone who can supply or edit a YAML config is trusted + (see below). +2. **Authenticated peers of a running device** — clients holding the device's + API encryption key / password, OTA password, or web server credentials. + +The security boundary is therefore **unauthenticated network traffic vs. those +trusted inputs.** A bug that lets an unauthenticated attacker cross it is a +security bug. + +## Config authors are host-equivalent by design + +Anyone who can supply or edit a configuration is **trusted with full code +execution on the host that runs `esphome`**, on purpose. This is what the product +does, not a flaw. A config author can already, through fully supported features: + +- Run arbitrary **Python** at validation/compile time via `external_components:` + (and other component-import mechanisms) — ESPHome imports those packages as + ordinary Python. +- Run arbitrary **shell** commands through the compile/validate/flash toolchain + that ESPHome invokes as subprocesses. +- Read and write arbitrary files reachable by the process (e.g. via `!include`, + `packages:`, `dashboard_import:`, and generated build output). + +Because of this, a malicious config author is equivalent to shell access on the +host running the build. + +## What is *not* a security vulnerability + +If exploiting an issue requires the ability to supply or edit configuration, it +is **not** a vulnerability in ESPHome, because that ability already grants host +code execution. This explicitly includes, among others: + +- Template / expression injection in substitutions or any YAML string value + (e.g. Jinja `${...}` evaluation reaching Python internals). This grants no + capability a config author lacks. +- `!include` / `packages:` / `dashboard_import:` reading or fetching content + from surprising or remote locations. +- The validator or compiler crashing or behaving unexpectedly on adversarial + YAML. +- ESPHome running as root in the official container — that is the documented + deployment posture, reachable by the same caller through the features above. + +These do not warrant a CVE or coordinated disclosure. Hardening in these areas +(for example, sandboxing template evaluation as least-surprise defense-in-depth) +is welcome as a normal enhancement PR, framed as cleanliness rather than a +security fix — not as a vulnerability remediation. + +## What we do defend + +These *are* security bugs in this repo, and we want to hear about them privately: + +- Memory-safety or protocol bugs in the generated **device firmware** that are + remotely triggerable over the network (native API, web server, OTA, BLE, + captive portal, etc.) **without** valid credentials. +- Authentication or encryption bypass on the device — reaching API calls, OTA + updates, or the web server without the configured key/password. +- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth + below their documented guarantees. + +## Explicitly out of scope + +- Local attackers who already have shell access on the host that runs `esphome`. +- Supply-chain attacks against ESPHome or its dependencies. +- Operator-supplied hostile YAML (covered above — config authoring is trusted). +- Attacks that require an already-authenticated device peer (someone who already + holds the API key / OTA / web credentials). +- Anything in the dashboard / device-builder — report that in its own repository + (linked at the top). +- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is + deprecated and being replaced by Device Builder; report dashboard issues there. +- Deployments where the operator removed protections or exposed credentials. See + the security best practices guide: + https://esphome.io/guides/security_best_practices/ + +## Reporting a vulnerability + +If you believe you've found an issue that crosses the unauthenticated boundary +above, please report it privately via GitHub Security Advisories rather than a +public issue. For issues that require config-write access, please review this +document first — they are very likely out of scope by design. For dashboard / +device-builder issues, report against that repository and consult its threat +model (linked at the top). From 9609d370c09a1bdcdedffe7061625455e23fbf2a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:22:19 -0500 Subject: [PATCH 080/343] Bump bundled esphome-device-builder to 1.0.12 (#17091) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index aa0406320c1..1d39644ab8c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.11 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 RUN \ platformio settings set enable_telemetry No \ From 63d8a344c564d3ba67b802ee913c546d3de8214a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 21 Jun 2026 11:32:35 -0700 Subject: [PATCH 081/343] [modbus] Fix parsing & split out server mode (#11969) --- esphome/components/modbus/__init__.py | 86 ++- esphome/components/modbus/modbus.cpp | 729 +++++++++++------- esphome/components/modbus/modbus.h | 244 ++++-- .../components/modbus/modbus_definitions.h | 26 +- esphome/components/modbus/modbus_helpers.cpp | 177 ++++- esphome/components/modbus/modbus_helpers.h | 98 ++- .../modbus_controller/modbus_controller.cpp | 2 +- .../modbus_controller/modbus_controller.h | 2 +- esphome/components/modbus_server/__init__.py | 9 +- .../modbus_server/modbus_server.cpp | 37 +- .../components/modbus_server/modbus_server.h | 24 +- .../components/modbus/modbus_helpers_test.cpp | 175 +++++ tests/components/modbus/modbus_test.cpp | 59 -- .../fixtures/uart_mock_modbus.yaml | 16 +- .../uart_mock_modbus_no_threshold.yaml | 11 +- .../uart_mock_modbus_server_controller.yaml | 6 +- ...ock_modbus_server_controller_multiple.yaml | 5 +- ...t_mock_modbus_server_controller_write.yaml | 4 +- .../fixtures/uart_mock_modbus_timing.yaml | 11 +- tests/integration/test_uart_mock_modbus.py | 22 +- 20 files changed, 1211 insertions(+), 532 deletions(-) delete mode 100644 tests/components/modbus/modbus_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index f6e0f98857d..492dfcaafea 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -14,7 +14,11 @@ DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) +ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus) +ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus) ModbusDevice = modbus_ns.class_("ModbusDevice") +ModbusClientDevice = modbus_ns.class_("ModbusClientDevice") +ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") MULTI_CONF = True CONF_ROLE = "role" @@ -22,29 +26,43 @@ CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" CONF_TURNAROUND_TIME = "turnaround_time" -ModbusRole = modbus_ns.enum("ModbusRole") -MODBUS_ROLES = { - "client": ModbusRole.CLIENT, - "server": ModbusRole.SERVER, -} +MODBUS_ROLES = ["client", "server"] -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(Modbus), - cv.Optional(CONF_ROLE, default="client"): cv.enum(MODBUS_ROLES), - cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="250ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="100ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean, - } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.typed_schema( + { + "client": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusClient), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_SEND_WAIT_TIME, default="2000ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TURNAROUND_TIME, default="600ms" + ): cv.positive_time_period_milliseconds, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + "server": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + }, + key=CONF_ROLE, + default_type="client", ) @@ -55,19 +73,19 @@ async def to_code(config): await uart.register_uart_device(var, config) - cg.add(var.set_role(config[CONF_ROLE])) if CONF_FLOW_CONTROL_PIN in config: pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) cg.add(var.set_flow_control_pin(pin)) - cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) - cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) - cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC])) + if config[CONF_ROLE] == "client": + cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) + cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) -def modbus_device_schema(default_address): +def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): + hub_type = ModbusClient if role == "client" else ModbusServer schema = { - cv.GenerateID(CONF_MODBUS_ID): cv.use_id(Modbus), + cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t @@ -98,8 +116,18 @@ def final_validate_modbus_device( ) -async def register_modbus_device(var, config): +async def register_modbus_client_device(var, config): + parent = await cg.get_variable(config[CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_address(config[CONF_ADDRESS])) + + +async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) + + +async def register_modbus_device(var, config): + return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 679ec34c0f5..136fc73db6f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -37,9 +37,36 @@ void Modbus::setup() { } void Modbus::loop() { - // First process all available incoming data. - this->receive_and_parse_modbus_bytes_(); + // Receive any available bytes from UART + this->receive_bytes_(); + // Parse bytes into frames and process them + this->parse_modbus_frames(); +} + +void ModbusClientHub::loop() { + // Call base class to receive bytes and parse frames + this->Modbus::loop(); + + // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response + if (this->waiting_for_response_.has_value()) { + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && + (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, + this->last_receive_check_ - this->last_send_); + if (wfr.device) + wfr.device->on_modbus_no_response(); + this->waiting_for_response_.reset(); + } + } + + // If there's no response pending and there's commands in the buffer + this->send_next_frame_(); +} + +bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts // when the buffer is filling the back half of the response @@ -47,250 +74,307 @@ void Modbus::loop() { (uint16_t) this->frame_delay_ms_, (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ : 0)); + + return this->last_receive_check_ - this->last_modbus_byte_ > timeout; +} + +int32_t Modbus::tx_delay_remaining() { // We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // It's critical in all timestamp comparisons that the left timestamp comes before the right one in time // If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop // then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout // So in this component we don't use any cached timestamp values to avoid these annoying bugs - if (millis() - this->last_modbus_byte_ > timeout) { - this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); - } + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))}); +} - // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response - if (this->waiting_for_response_ != 0 && - millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && - (this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", - this->waiting_for_response_, millis() - this->last_send_); - this->waiting_for_response_ = 0; - } - - // If there's no response pending and there's commands in the buffer - this->send_next_frame_(); +int32_t ModbusClientHub::tx_delay_remaining() { + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { - const uint32_t now = millis(); - - // We block transmission in any of these case: + // We block transmission in any of these cases: // 1. There are bytes in the UART Rx buffer // 2. There are bytes in our Rx buffer - // 3. We're waiting for a response - // 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) - // 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming) - // 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message - return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) || - (now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ + - (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) || - (now - this->last_modbus_byte_ < - this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)); + // 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) + // 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming) + // N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by + // send_frame_. + return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; } -bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); } +bool ModbusClientHub::tx_blocked() { + // We block transmission in any of these case: + // 1. We're waiting for a response + // 2. Any of the base class tx_blocked conditions + return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked(); +} -void Modbus::receive_and_parse_modbus_bytes_() { - // Read all available bytes in batches to reduce UART call overhead. - size_t avail = this->available(); - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; +bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); } + +void Modbus::receive_bytes_() { + this->last_receive_check_ = millis(); + size_t bytes = this->available(); + + if (bytes) { + size_t buffer_size = this->rx_buffer_.size(); + this->last_modbus_byte_ = this->last_receive_check_; + this->rx_buffer_.resize(buffer_size + bytes); + if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) { + this->rx_buffer_.resize(buffer_size); + return; } - avail -= to_read; - for (size_t i = 0; i < to_read; i++) { - if (this->rx_buffer_.empty()) { - ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } else { - ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } - - // If the bytes in the rx buffer do not parse, clear out the buffer - if (!this->parse_modbus_byte_(buf[i])) { - this->clear_rx_buffer_(LOG_STR("parse failed"), true); - } - this->last_modbus_byte_ = millis(); + if (buffer_size == 0) { + ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send", + this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_); } } } -bool Modbus::parse_modbus_byte_(uint8_t byte) { - size_t at = this->rx_buffer_.size(); - this->rx_buffer_.push_back(byte); - const uint8_t *raw = &this->rx_buffer_[0]; +void ModbusClientHub::parse_modbus_frames() { + if (!this->rx_buffer_.empty()) { + size_t size; + do { + size = this->rx_buffer_.size(); + if (!this->parse_modbus_server_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); + } while (!this->rx_buffer_.empty() && size > this->rx_buffer_.size()); + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); + } +} - // Byte 0: modbus address (match all) - if (at == 0) - return true; - // Byte 1: function code - if (at == 1) - return true; - // Byte 2: Size (with modbus rtu function code 4/3) - // See also https://en.wikipedia.org/wiki/Modbus - if (at == 2) - return true; - - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; - - uint8_t data_len = raw[2]; - uint8_t data_offset = 3; - - // Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes - if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) || - ((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) { - // Handle user-defined function, since we don't know how big this ought to be, - // ideally we should delegate the entire length detection to whatever handler is - // installed, but wait, there is the CRC, and if we get a hit there is a good - // chance that this is a complete message ... admittedly there is a small chance is - // isn't but that is quite small given the purpose of the CRC in the first place - - data_len = at - 2; - data_offset = 1; - - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - - if (computed_crc != remote_crc) - return true; - - ESP_LOGD(TAG, "User-defined function %02X found", function_code); - - } else { - // data starts at 2 and length is 4 for read registers commands - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_COILS || - function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || - function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data_offset = 2; - data_len = 4; - } else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (at < 6) { - return true; - } - data_offset = 2; - // starting address (2 bytes) + quantity of registers (2 bytes) + byte count itself (1 byte) + actual byte count - data_len = 2 + 2 + 1 + raw[6]; +void ModbusServerHub::parse_modbus_frames() { + while (!this->rx_buffer_.empty()) { + size_t size = this->rx_buffer_.size(); + ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); + bool retry_as_client = false; + if (this->expecting_peer_response_ != 0) { + if (!this->parse_modbus_server_frame_()) { + ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; + } else if (this->timeout_() && size == this->rx_buffer_.size()) { + // If we timed out and the above parse attempt did not consume data, stop expecting a response + ESP_LOGV(TAG, + "Stop expecting peer response from %" PRIu8 " due to timeout after partial response, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; } } else { - // the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands - if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - data_offset = 2; - data_len = 4; - } - } - - // Error ( msb indicates error ) - // response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - data_offset = 2; - data_len = 1; - } - - // Byte data_offset..data_offset+data_len-1: Data - if (at < data_offset + data_len) - return true; - - // Byte 3+data_len: CRC_LO (over all bytes) - if (at == data_offset + data_len) - return true; - - // Byte data_offset+len+1: CRC_HI (over all bytes) - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - if (computed_crc != remote_crc) { - if (this->disable_crc_) { - ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - } else { - ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - return false; - } + if (!this->parse_modbus_client_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); } + // Stop if the buffer didn't shrink (no frame consumed) and no mode switch triggered a retry + if (!retry_as_client && size <= this->rx_buffer_.size()) + break; } - std::vector data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len); - bool found = false; - for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8), - uint16_t(data[3]) | (uint16_t(data[2]) << 8)); - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, data); - } - } else { // We're a client - // Is it an error response? - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - uint8_t exception = raw[2]; - ESP_LOGW(TAG, - "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 - "ms after last send", - function_code, exception, address, millis() - this->last_send_); - if (this->waiting_for_response_ == address) { - device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); - } else { - // Ignore modbus exception not related to a pending command - ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address); - } - } else { // Not an error response - if (this->waiting_for_response_ == address) { - device->on_modbus_data(data); - } else { - // Ignore modbus response not related to a pending command - ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send", - address, millis() - this->last_send_); - } - } - } - } + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); +} + +uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { + // Custom functions could be any length - we have to rely on the CRC to determine completeness. + // If a CRC match is never found, the buffer will eventually overflow and be cleared. + const uint8_t *raw = &this->rx_buffer_[0]; + const size_t size = this->rx_buffer_.size(); + for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { + if (crc16(raw, len) == 0) + return len; + } + return 0; +} + +bool Modbus::parse_modbus_server_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::server_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; } - if (!found && this->role == ModbusRole::CLIENT) { - ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address, - millis() - this->last_send_); - } + // Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply + // synchronously. We can safely point directly into rx_buffer_ and avoid a copy. + uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + const uint8_t *data = this->rx_buffer_.data() + data_offset; + uint16_t data_len = frame_length - 2 - data_offset; - this->clear_rx_buffer_(LOG_STR("parse succeeded")); - - if (this->waiting_for_response_ == address) - this->waiting_for_response_ = 0; + this->process_modbus_server_frame(address, function_code, data, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); return true; } -void Modbus::send_next_frame_() { - if (this->tx_buffer_.empty()) +bool ModbusServerHub::parse_modbus_client_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::client_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; + } + + // Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends + // a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked. + // This requires copying the frame data to a local buffer beforehand. + uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + uint16_t data_len = frame_length - 2 - data_offset; + uint8_t data[MAX_FRAME_SIZE] = {}; + std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); + + this->process_modbus_client_frame_(address, function_code, data, data_len); + + return true; +} + +void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + if (!this->waiting_for_response_.has_value()) { + ESP_LOGW(TAG, + "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", + address, function_code, this->last_modbus_byte_ - this->last_send_); return; + } else { // We are waiting for a response + // Check if the response matches the expected address and function code - if (this->tx_blocked()) - return; + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_function_code = wfr.frame.data.get()[1]; + if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { + ESP_LOGW(TAG, + "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 + "ms after last send", + address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, + this->last_modbus_byte_ - this->last_send_); + // Invalidate the waiting device so it won't process this response. + if (wfr.device) + wfr.device->on_modbus_no_response(); + wfr.interrupted = true; + wfr.device = nullptr; + return; + } - const ModbusDeviceCommand &frame = this->tx_buffer_.front(); + if (wfr.interrupted) { + ESP_LOGW(TAG, + "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 + "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + return; + } else { // We have a valid device waiting for this response - if (this->role == ModbusRole::CLIENT) { - this->waiting_for_response_ = frame.data.get()[0]; + ModbusClientDevice *device = wfr.device; + this->waiting_for_response_.reset(); + // Is it an error response? + if (helpers::is_function_code_exception(function_code)) { + uint8_t exception = len > 0 ? data[0] : 0; + ESP_LOGW(TAG, + "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", + function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + if (device) + device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); + + } else if (device) { // Not an error response + // on_modbus_data is existing public API taking const std::vector& + device->on_modbus_data(std::vector(data, data + len)); + } else { // Not an error response, but no device to respond to + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + } + } + } +} + +void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { + for (auto *device : this->devices_) { + if (device->address_ == address) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); + } + } + + if (this->expecting_peer_response_ == address) { + ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address); + } else { + ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address); + } + + // This always resets, even if the address doesn't match. + // If an unexpected response is received, we can't trust that a correct response will follow (it shouldn't). + this->expecting_peer_response_ = 0; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + bool found = false; + + for (auto *device : this->devices_) { + if (device->address_ == address) { + found = true; + + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || + static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { + device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), + helpers::get_data(data, 2)); + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + device->on_modbus_write_registers(function_code, std::vector(data, data + len)); + } else { + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + } + } + } + + if (!found) { + this->expecting_peer_response_ = address; + ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + } +} + +bool Modbus::send_frame_(const ModbusFrame &frame) { + if (this->tx_blocked()) { + ESP_LOGE(TAG, "Attempted to send while transmission blocked"); + return false; + } + if (frame.size > MAX_FRAME_SIZE) { + ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); + return false; + } + + const int32_t tx_delay_remaining = this->tx_delay_remaining(); + if (tx_delay_remaining > 0) { + delay(tx_delay_remaining); } if (this->flow_control_pin_ != nullptr) { @@ -304,123 +388,190 @@ void Modbus::send_next_frame_() { this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } + uint32_t now = millis(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), - millis() - this->last_send_); - this->last_send_ = millis(); + ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", + format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + now - this->last_modbus_byte_); + this->last_send_ = now; + return true; +} + +void ModbusClientHub::send_next_frame_() { + if (this->tx_buffer_.empty()) { + return; + } + + if (this->tx_blocked()) { + return; + } + + ModbusDeviceCommand &command = this->tx_buffer_.front(); + + if (this->send_frame_(command.frame)) { + this->waiting_for_response_ = std::move(command); + } else { + if (command.device) + command.device->on_modbus_not_sent(); + } + this->tx_buffer_.pop_front(); + if (!this->tx_buffer_.empty()) { ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); } } -void Modbus::dump_config() { +void ModbusClientHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Send Wait Time: %d ms\n" - " Turnaround Time: %d ms\n" - " Frame Delay: %d ms\n" - " Long Rx Buffer Delay: %d ms\n" - " CRC Disabled: %s", + " Send Wait Time: %" PRIu16 " ms\n" + " Turnaround Time: %" PRIu16 " ms\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, - this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_)); + this->long_rx_buffer_delay_ms_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } +void ModbusServerHub::dump_config() { + ESP_LOGCONFIG(TAG, + "Modbus:\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", + this->frame_delay_ms_, this->long_rx_buffer_delay_ms_); + LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); +} + float Modbus::get_setup_priority() const { // After UART bus return setup_priority::BUS - 1.0f; } -void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len, const uint8_t *payload) { - static const size_t MAX_VALUES = 128; - - // Only check max number of registers for standard function codes - // Some devices use non standard codes like 0x43 - if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES); +void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { + const uint16_t len = static_cast(2 + payload.size()); + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); return; } - - uint8_t data[MAX_FRAME_SIZE]; - size_t pos = 0; - - data[pos++] = address; - data[pos++] = function_code; - if (this->role == ModbusRole::CLIENT) { - data[pos++] = start_address >> 8; - data[pos++] = start_address >> 0; - if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && - function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data[pos++] = number_of_entities >> 8; - data[pos++] = number_of_entities >> 0; - } - } - - if (payload != nullptr) { - if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple - data[pos++] = payload_len; // Byte count is required for write - } else { - payload_len = 2; // Write single register or coil - } - if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC) - ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len); - return; - } - for (int i = 0; i < payload_len; i++) { - data[pos++] = payload[i]; - } - } - - this->queue_raw_(data, pos); + uint8_t raw_frame[MAX_RAW_SIZE]; + raw_frame[0] = address; + raw_frame[1] = function_code; + std::memcpy(raw_frame + 2, payload.data(), payload.size()); + this->send_raw_(raw_frame, len); } -// Helper function for lambdas -// Send raw command. Except CRC everything must be contained in payload -void Modbus::send_raw(const std::vector &payload) { - if (payload.empty()) { +// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { + if (pdu_len == 0) { + if (device) + device->on_modbus_not_sent(); return; } - // Frame size: payload + CRC(2) - if (payload.size() + 2 > MAX_FRAME_SIZE) { - ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE); - return; - } - // Use stack buffer - Modbus frames are small and bounded - uint8_t data[MAX_FRAME_SIZE]; - std::memcpy(data, payload.data(), payload.size()); - - this->queue_raw_(data, payload.size()); -} - -// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying -// of data into vectors -void Modbus::queue_raw_(const uint8_t *data, uint16_t len) { if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { - this->tx_buffer_.emplace_back(data, len); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + this->tx_buffer_.emplace_back(device, address, pdu, pdu_len); } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len)); + ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + if (device) + device->on_modbus_not_sent(); } } -void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { - size_t at = this->rx_buffer_.size(); - if (at > 0) { +void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), + tx_buffer.end()); + + if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().frame.data[0] == address) { + ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} +void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }), + tx_buffer.end()); + + if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().device == device) { + ESP_LOGV(TAG, "Clearing waiting for response"); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} + +void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { + if (payload.size() < 2) { + if (device) + device->on_modbus_not_sent(); + return; + } + this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); +} + +// Send raw command for server replies immediately. Except CRC everything must be contained in payload +void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { + if (len == 0) { + return; + } + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); + return; + } + + // In the rare case that the server is blocked (frame delay has not elapsed), we delay the send. + // This should only happen at low baud rates with long frame delays. + if (this->tx_blocked()) { + // Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame + // without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures + // only one deferred send is pending, so a single buffer is sufficient. + std::memcpy(this->deferred_payload_.data(), payload, len); + this->deferred_payload_len_ = len; + this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { + ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, + this->deferred_payload_len_ - 1); + this->send_frame_(frame); + }); + } else { + ModbusFrame frame(payload[0], payload + 1, len - 1); + this->send_frame_(frame); + } +} + +void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { + size_t bytes = this->rx_buffer_.size(); + if (bytes_to_clear > 0 && bytes >= bytes_to_clear) + bytes = bytes_to_clear; + if (bytes > 0) { if (warn) { - ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } else { - ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } - this->rx_buffer_.clear(); + if (bytes == this->rx_buffer_.size()) { + this->rx_buffer_.clear(); + } else { + this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes); + } } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 26f64401be0..86337442c64 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -4,33 +4,32 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/modbus/modbus_definitions.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include #include #include #include -#include +#include +#include namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; +static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -enum ModbusRole { - CLIENT, - SERVER, -}; - -class ModbusDevice; - -struct ModbusDeviceCommand { +struct ModbusFrame { // Frame with exact-size allocation to avoid std::vector overhead std::unique_ptr data; uint16_t size; // Modbus RTU max is 256 bytes - ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique(len + 2)), size(len + 2) { - std::memcpy(this->data.get(), src, len); - auto crc = crc16(data.get(), len); - data[len + 0] = crc >> 0; - data[len + 1] = crc >> 8; + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) + : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { + data[0] = address; + memcpy(data.get() + 1, pdu, pdu_len); + auto crc = crc16(data.get(), pdu_len + 1); + data[pdu_len + 1] = crc >> 0; + data[pdu_len + 2] = crc >> 8; } }; @@ -39,86 +38,197 @@ class Modbus : public uart::UARTDevice, public Component { Modbus() = default; void setup() override; - void loop() override; - void dump_config() override; - - void register_device(ModbusDevice *device) { this->devices_.push_back(device); } - float get_setup_priority() const override; - bool tx_buffer_empty(); - bool tx_blocked(); + virtual bool tx_blocked(); - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr); - void send_raw(const std::vector &payload); - void set_role(ModbusRole role) { this->role = role; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } - void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } - void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } - void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; } - - ModbusRole role; protected: - bool parse_modbus_byte_(uint8_t byte); - void receive_and_parse_modbus_bytes_(); - void clear_rx_buffer_(const LogString *reason, bool warn = false); - void send_next_frame_(); - void queue_raw_(const uint8_t *data, uint16_t len); + void receive_bytes_(); + bool timeout_(); + virtual int32_t tx_delay_remaining(); + virtual void parse_modbus_frames() = 0; + bool parse_modbus_server_frame_(); + virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) = 0; + void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); + bool send_frame_(const ModbusFrame &frame); + // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. + // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. + uint16_t find_custom_frame_end_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; + uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; - uint16_t send_wait_time_{250}; - uint16_t turnaround_delay_ms_{100}; - uint8_t waiting_for_response_{0}; - bool disable_crc_{false}; GPIOPin *flow_control_pin_{nullptr}; std::vector rx_buffer_; - std::vector devices_; +}; + +class ModbusClientDevice; +class ModbusServerDevice; + +struct ModbusDeviceCommand { + ModbusClientDevice *device; + ModbusFrame frame; + bool interrupted{false}; + + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) + : device(device), frame(address, src, len) {} +}; + +class ModbusClientHub : public Modbus { + public: + ModbusClientHub() = default; + void dump_config() override; + void loop() override; + void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } + void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } + bool tx_buffer_empty(); + bool tx_blocked() override; + ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, + uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { + this->send_pdu(address, + helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities, + payload, payload_len), + device); + }; + void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + this->queue_raw_(address, pdu.data(), pdu.size(), device); + } + void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); + void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); + void clear_tx_queue_for_device(ModbusClientDevice *device); + + protected: + int32_t tx_delay_remaining() override; + void parse_modbus_frames() override; + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void send_next_frame_(); + void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); + + uint16_t send_wait_time_{2000}; + uint16_t turnaround_delay_ms_{0}; + std::optional waiting_for_response_; + // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling // may change at run time. std::deque tx_buffer_; }; -class ModbusDevice { +class ModbusServerHub : public Modbus { public: - void set_parent(Modbus *parent) { parent_ = parent; } - void set_address(uint8_t address) { address_ = address; } - virtual void on_modbus_data(const std::vector &data) = 0; - virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, - const uint8_t *payload = nullptr) { - this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload); - } - void send_raw(const std::vector &payload) { this->parent_->send_raw(payload); } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - std::vector error_response; - error_response.reserve(3); - error_response.push_back(this->address_); - error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK); - error_response.push_back(static_cast(exception_code)); - this->send_raw(error_response); - } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !ready_for_immediate_send(); } - bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); } + ModbusServerHub() = default; + void dump_config() override; + void send(uint8_t address, uint8_t function_code, const std::vector &payload); + ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") + void send_raw(const std::vector &payload) { + this->send_raw_(payload.data(), static_cast(payload.size())); + }; + void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend Modbus; + friend class ModbusServerDevice; - Modbus *parent_; - uint8_t address_; + void parse_modbus_frames() override; + bool parse_modbus_client_frame_(); + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void send_raw_(const uint8_t *payload, uint16_t len); + uint8_t expecting_peer_response_{0}; + std::vector devices_; + + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. + // Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation. + std::array deferred_payload_; + uint16_t deferred_payload_len_{0}; +}; + +class ModbusClientDevice { + public: + ModbusClientDevice() = default; + ModbusClientDevice(ModbusClientHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusClientDevice() { + if (this->parent_ != nullptr) + this->clear_tx_queue_for_device(); + } + ModbusClientDevice(const ModbusClientDevice &) = delete; + ModbusClientDevice &operator=(const ModbusClientDevice &) = delete; + ModbusClientDevice(ModbusClientDevice &&) = delete; + ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; + void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_data(const std::vector &data) {} + virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} + virtual void on_modbus_not_sent() {} + virtual void on_modbus_no_response() {} + void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, + const uint8_t *payload = nullptr) { + this->parent_->send_pdu(this->address_, + helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities, + payload, payload_len), + this); + } + void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } + inline void clear_tx_queue_for_address(bool clear_sent = true) { + this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); + } + inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } + + // If more than one device is connected block sending a new command before a response is received + ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") + bool waiting_for_response() { return !this->ready_for_immediate_send(); } + bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } + + protected: + ModbusClientHub *parent_{nullptr}; + uint8_t address_{0}; +}; + +// This is for compatibility with external components using the former class name +using ModbusDevice = ModbusClientDevice; + +class ModbusServerDevice { + public: + ModbusServerDevice() = default; + ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusServerDevice() = default; + ModbusServerDevice(const ModbusServerDevice &) = delete; + ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; + ModbusServerDevice(ModbusServerDevice &&) = delete; + ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; + void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; + virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; + void send(uint8_t function, const std::vector &payload) { + this->parent_->send(this->address_, function, payload); + } + void send_raw(const std::vector &payload) { + this->parent_->send_raw_(payload.data(), static_cast(payload.size())); + } + void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), + static_cast(exception_code)}; + this->parent_->send_raw_(error_response, 3); + } + + protected: + friend ModbusServerHub; + + ModbusServerHub *parent_{nullptr}; + uint8_t address_{0}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index fb8c011259f..49172b9dca4 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -14,7 +14,8 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E enum class ModbusFunctionCode : uint8_t { - CUSTOM = 0x00, + INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). + CUSTOM = 0x00, // The CUSTOM alias should be removed in future. READ_COILS = 0x01, READ_DISCRETE_INPUTS = 0x02, READ_HOLDING_REGISTERS = 0x03, @@ -35,19 +36,11 @@ enum class ModbusFunctionCode : uint8_t { READ_FIFO_QUEUE = 0x18, // not implemented }; -/*Allow comparison operators between ModbusFunctionCode and uint8_t*/ +/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/ inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast(rhs); } inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast(rhs)); } -inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) < rhs; } -inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast(rhs); } -inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) <= rhs; } -inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast(rhs); } -inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) > rhs; } -inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast(rhs); } -inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) >= rhs; } -inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast(rhs); } // 4.3 MODBUS Data model enum class ModbusRegisterType : uint8_t { @@ -75,12 +68,21 @@ enum class ModbusExceptionCode : uint8_t { }; // 6.12 16 (0x10) Write Multiple registers: -const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B + +// 6.1 01 (0x01) Read Coils +// 6.2 02 (0x02) Read Discrete Inputs +static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0 +static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) +static constexpr uint16_t MIN_FRAME_SIZE = 4; +static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 89dc3c08bc1..4cddfca104a 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -1,10 +1,83 @@ #include "modbus_helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +uint16_t server_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + if (is_function_code_exception(frame[1])) { + return 5; // address(1) + function(1) + exception(1) + CRC(2) + } + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + +uint16_t client_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) + return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + + // write quantity(2) + byte count(1) + data + CRC(2) + return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + static size_t required_payload_size(SensorValueType sensor_value_type) { switch (sensor_value_type) { case SensorValueType::U_WORD: @@ -67,7 +140,7 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy } int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { + uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -75,6 +148,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens if (static_cast(offset) > data.size()) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), data.size()); + if (error_return) + *error_return = true; return value; } @@ -87,6 +162,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), data.size(), required_size); + if (error_return) + *error_return = true; return value; } @@ -136,4 +213,102 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens } return value; } + +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values, + size_t values_len) { + if (is_function_code_read(static_cast(function_code))) { + if (values != nullptr || values_len > 0) { + ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", + static_cast(function_code)); + } + } else if (is_function_code_write(static_cast(function_code))) { + if (values == nullptr || values_len == 0) { + ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); + return {}; + } + } else { + ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); + return {}; + } + + if (number_of_entities == 0) { + ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); + return {}; + } + + switch (function_code) { + case ModbusFunctionCode::READ_COILS: + if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + break; // number_of_entities is ignored for single write, so no need to validate + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); + return {}; + } + break; + default: + ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast(function_code)); + return {}; + } + + StaticVector pdu; + pdu.push_back(static_cast(function_code)); + pdu.push_back(start_address >> 8); + pdu.push_back(start_address >> 0); + if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && + function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + pdu.push_back(number_of_entities >> 8); + pdu.push_back(number_of_entities >> 0); + } + + if (is_function_code_write(static_cast(function_code))) { + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values + static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; + if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { + ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len, + MAX_WRITE_MULTIPLE_VALUES_LEN); + return {}; + } + pdu.push_back(values_len); // Byte count is required for write multiple + for (size_t i = 0; i < values_len; i++) + pdu.push_back(values[i]); + } else { + // Write single register or coil (2 bytes) + if (values_len < 2) { + ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); + return {}; + } + pdu.push_back(values[0]); + pdu.push_back(values[1]); + } + } + return pdu; +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 84897bcad35..b637d872cf7 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -9,6 +9,58 @@ namespace esphome::modbus::helpers { +inline bool is_function_code_read(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::READ_COILS || + masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || + masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || + masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS; +} + +inline bool is_function_code_write(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || + masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; +} + +inline bool is_function_code_exception(uint8_t function_code) { + return (static_cast(function_code) & FUNCTION_CODE_EXCEPTION_MASK) != 0; +} + +inline bool is_function_code_custom(uint8_t function_code) { + uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK; + return (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END) || + (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); +} + +// Returns the expected length of a server response frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t server_frame_length(const uint8_t *frame, size_t size); + +// Returns the expected length of a client request frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t client_frame_length(const uint8_t *frame, size_t size); + +inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { + if (size < 2) + return 0; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + return 3; // address(1) + function(1) + byte count(1) + data + CRC(2) + default: + return 2; + } +} + +inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } + enum class SensorValueType : uint8_t { RAW = 0x00, // variable length U_WORD = 0x1, // 1 Register unsigned @@ -41,21 +93,21 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t case ModbusRegisterType::READ: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { +inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) { switch (reg_type) { case ModbusRegisterType::COIL: - return ModbusFunctionCode::WRITE_SINGLE_COIL; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::CUSTOM; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL; case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + // These register types can't be written (per spec) case ModbusRegisterType::READ: + case ModbusRegisterType::DISCRETE_INPUT: default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } @@ -112,31 +164,31 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { * @param buffer_offset offset in bytes. * @return value of type T extracted from buffer */ -template T get_data(const std::vector &data, size_t buffer_offset) { +template T get_data(const uint8_t *data, size_t buffer_offset) { if (sizeof(T) == sizeof(uint8_t)) { return T(data[buffer_offset]); } if (sizeof(T) == sizeof(uint16_t)) { return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); } - if (sizeof(T) == sizeof(uint32_t)) { return static_cast(get_data(data, buffer_offset)) << 16 | static_cast(get_data(data, buffer_offset + 2)); } - if (sizeof(T) == sizeof(uint64_t)) { return static_cast(get_data(data, buffer_offset)) << 32 | (static_cast(get_data(data, buffer_offset + 4))); } - static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || sizeof(T) == sizeof(uint64_t), "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); - return T{}; } +template T get_data(const std::vector &data, size_t buffer_offset) { + return get_data(data.data(), buffer_offset); +} + /** Extract coil data from modbus response buffer * Responses for coil are packed into bytes . * coil 3 is bit 3 of the first response byte @@ -188,7 +240,27 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy * @return 64-bit number of the payload */ int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask); + uint32_t bitmask, bool *error_return = nullptr); + +/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. + * @param function_code the modbus function code to use. One of: + * READ_COILS + * READ_DISCRETE_INPUTS + * READ_HOLDING_REGISTERS + * READ_INPUT_REGISTERS + * WRITE_SINGLE_COIL + * WRITE_SINGLE_REGISTER + * WRITE_MULTIPLE_COILS + * WRITE_MULTIPLE_REGISTERS + * @param start_address coil/register/input starting address + * @param number_of_entities number of coils/registers/inputs to read/write + * @param values optional payload bytes to write (nullptr for read commands) + * @param values_len length of values array + * @return PDU (function code + data, no address, no CRC) + */ +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values = nullptr, + size_t values_len = 0); inline std::vector float_to_payload(float value, SensorValueType value_type) { int64_t val; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 6604276cc20..9246239ef95 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -201,7 +201,7 @@ void ModbusController::update() { // walk through the sensors and determine the register ranges to read size_t ModbusController::create_register_ranges_() { this->register_ranges_.clear(); - if (this->parent_->role == modbus::ModbusRole::CLIENT && this->sensorset_.empty()) { + if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); return 0; } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index ba86c2cd166..4f674b2675e 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusDevice { +class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 5182bc05d12..2ba7f41b832 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -27,7 +27,7 @@ MULTI_CONF = True modbus_server_ns = cg.esphome_ns.namespace("modbus_server") ModbusServer = modbus_server_ns.class_( - "ModbusServer", cg.Component, modbus.ModbusDevice + "ModbusServer", cg.Component, modbus.ModbusServerDevice ) ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") @@ -44,7 +44,7 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), } - ).extend(modbus.modbus_device_schema(0x01)), + ).extend(modbus.modbus_device_schema(0x01, role="server")), ) @@ -119,6 +119,5 @@ async def to_code(config): ) ) cg.add(var.add_server_register(server_register_var)) - cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) - return await modbus.register_modbus_device(var, config) + return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e5ea2efa4d0..c294d088889 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -5,6 +5,7 @@ namespace esphome::modbus_server { using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; +using modbus::helpers::payload_to_number; static const char *const TAG = "modbus_server"; @@ -16,7 +17,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star this->address_, function_code, start_address, number_of_registers); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } @@ -30,9 +31,10 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star break; } int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value).c_str()); + server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); std::vector payload; payload.reserve(server_register->register_count * 2); @@ -49,7 +51,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star (current_address <= this->server_courtesy_response_.register_last_address)) { ESP_LOGV(TAG, "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", + "Returning default value: %" PRIu16 ".", current_address, this->server_courtesy_response_.register_value); sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); current_address += 1; // Just increment by 1, as the default response is a single register @@ -64,20 +66,22 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star } std::vector response; + if (number_of_registers != sixteen_bit_response.size()) + ESP_LOGW(TAG, "Response size not matched to request register count."); + response.push_back(sixteen_bit_response.size() * 2); // actual byte count for (auto v : sixteen_bit_response) { auto decoded_value = decode_value(v); response.push_back(decoded_value[0]); response.push_back(decoded_value[1]); } - - this->send(function_code, start_address, number_of_registers, response.size(), response.data()); + this->send(function_code, response); } void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { uint16_t number_of_registers; uint16_t payload_offset; - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { if (data.size() < 5) { ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -85,13 +89,15 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v } number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; } uint16_t payload_size = data[4]; if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", + ESP_LOGW(TAG, + "Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 + "). Sending exception response.", payload_size, number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; @@ -103,7 +109,7 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return; } payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { if (data.size() < 4) { ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -148,15 +154,22 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { return server_register->write_lambda != nullptr; })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + ESP_LOGW(TAG, "Invalid register address. Sending exception response."); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } // Actually write to the registers: if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); - return server_register->write_lambda(number); + bool error = false; + int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); + if (error) { + return false; + } else { + return server_register->write_lambda(number); + } })) { + ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); return; } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0fc2e0bef5d..fa1376542c2 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -52,32 +52,34 @@ class ServerRegister { }; } - // Formats a raw value into a string representation based on the value type for debugging - std::string format_value(int64_t value) const { - // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) - // plus null terminator = 43, rounded to 44 for 4-byte alignment - char buf[44]; + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + static constexpr size_t FORMAT_VALUE_BUF_SIZE = 44; + + // Formats a raw value into a caller-provided buffer based on the value type for debugging. + // Returns buf for convenience. + const char *format_value(int64_t value, char *buf, size_t buf_size) const { switch (this->value_type) { case SensorValueType::U_WORD: case SensorValueType::U_DWORD: case SensorValueType::U_DWORD_R: case SensorValueType::U_QWORD: case SensorValueType::U_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast(value)); return buf; case SensorValueType::S_WORD: case SensorValueType::S_DWORD: case SensorValueType::S_DWORD_R: case SensorValueType::S_QWORD: case SensorValueType::S_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; case SensorValueType::FP32_R: case SensorValueType::FP32: - buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + buf_append_printf(buf, buf_size, 0, "%.1f", bit_cast(static_cast(value))); return buf; default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; } } @@ -89,12 +91,10 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusDevice { +class ModbusServer : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; - /// Not used for ModbusServer. - void on_modbus_data(const std::vector &data) override{}; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index e1b4fb2aa6e..cd260f410a5 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -4,6 +4,181 @@ namespace esphome::modbus::helpers { +using FC = ModbusFunctionCode; + +// --- server_frame_length --------------------------------------------------- +// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from +// tests/integration/fixtures/uart_mock_modbus.yaml. + +TEST(ModbusServerFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) { + // inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7 + const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7); +} + +TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) { + const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2); +} + +TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) { + const uint8_t frame[] = {0x01, 0x03}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, ExceptionResponse) { + // exception_response fixture: function code 0x83 has the exception bit set + const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, WriteResponsesAreFixed) { + for (FC fc : + {FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusServerFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- client_frame_length --------------------------------------------------- + +TEST(ModbusClientFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) { + // basic_register request fixture is a read-holding request -> 8 bytes + const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A}; + EXPECT_EQ(client_frame_length(read, sizeof(read)), 8); + for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL, + FC::WRITE_SINGLE_REGISTER}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) { + // write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6] + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4); +} + +TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) { + const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2); +} + +TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); +} + +TEST(ModbusClientFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- create_client_pdu ----------------------------------------------------- +// PDU = function code + data (no address, no CRC). + +TEST(ModbusCreateClientPdu, ReadHolding) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1); + const std::vector expected{0x03, 0x00, 0x03, 0x00, 0x01}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values)); + const std::vector expected{0x06, 0x00, 0x03, 0x00, 0x0B}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) { + const uint8_t values[] = {0x00}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) { + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(values)); + const std::vector expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) { + std::vector values(MAX_PDU_SIZE - 6 + 1, 0xAA); + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size()); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) { + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +// Regression: coils allow up to 2000 entities, well above the 125 register limit. +// A switch fall-through previously subjected coil/discrete reads to the register limit. +TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) { + const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity); + const std::vector expected{0x01, 0x00, 0x00, static_cast(quantity >> 8), + static_cast(quantity & 0xFF)}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values, + sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp deleted file mode 100644 index afe5ced082b..00000000000 --- a/tests/components/modbus/modbus_test.cpp +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include "esphome/components/modbus/modbus.h" -#include "esphome/core/helpers.h" - -namespace esphome::modbus { - -// Exposes protected methods for testing. -class TestModbus : public Modbus { - public: - bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } - void test_clear_rx_buffer() { this->rx_buffer_.clear(); } - void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } -}; - -class MockDevice : public ModbusDevice { - public: - void on_modbus_data(const std::vector &data) override { this->data_received = true; } - bool data_received{false}; -}; - -TEST(ModbusTest, TwoByteRegressionTest) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - // First byte (at=0) - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); - // Second byte (at=1) - // This used to reach raw[2] because it skipped the if(at==2) check, causing a - // buffer overflow. - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); -} - -TEST(ModbusTest, TestValidFrame) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - - MockDevice device; - device.set_parent(&modbus); - device.set_address(0x01); - modbus.register_device(&device); - modbus.set_waiting(0x01); - - // Address 1, Function 3, Length 2, Data 0x1234 - uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; - uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); - - std::vector frame; - for (uint8_t b : frame_data) - frame.push_back(b); - frame.push_back(crc & 0xFF); - frame.push_back((crc >> 8) & 0xFF); - - for (size_t i = 0; i < frame.size(); i++) { - bool result = modbus.test_parse_modbus_byte(frame[i]); - EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; - } - EXPECT_TRUE(device.data_received); -} - -} // namespace esphome::modbus diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index da36da4de18..7e2bcff3ef2 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -49,15 +49,16 @@ modbus_controller: - address: 1 id: modbus_controller_ok max_cmd_retries: 2 - update_interval: 1s + # Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button + update_interval: never - address: 2 id: modbus_controller_slow max_cmd_retries: 0 - update_interval: 1s + update_interval: never - address: 3 id: modbus_controller_offline max_cmd_retries: 0 - update_interval: 1s + update_interval: never sensor: - platform: modbus_controller @@ -91,4 +92,11 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); + id(modbus_controller_slow).set_update_interval(1000); + id(modbus_controller_slow).start_poller(); + id(modbus_controller_offline).set_update_interval(1000); + id(modbus_controller_offline).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index 9bc4dc50e92..5a7c9b74dc4 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -54,7 +54,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -64,4 +68,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index 1e5f5a3389e..20306bd73a4 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -53,8 +53,8 @@ modbus: modbus_controller: - address: 1 modbus_id: virtual_modbus_controller - update_interval: 1s id: modbus_controller_1 + update_interval: 1s modbus_server: - address: 1 @@ -176,6 +176,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml index e68edd22715..18423be6d58 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -113,7 +113,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_server_2).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index 94890e90de3..b3b5e76e317 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -326,6 +326,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index c670864085b..c62e0188bb1 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -53,7 +53,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -63,4 +67,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e8dfa1b8226..2c437341c6c 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -127,15 +127,18 @@ async def test_uart_mock_modbus_timing( ) -> None: """Test modbus timing with multi-register SDM meter response.""" + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio @@ -148,26 +151,25 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. - Bus-level warnings (CRC failures, buffer clears) are expected during - chunked reassembly — the test only verifies the final value arrives. + Bus-level warnings (CRC/parse failures, buffer clears) are NOT expected during + chunked reassembly, if timeouts are set properly — these warnings indicate undersized timeouts. """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server( yaml_config: str, run_compiled: RunCompiledFunction, @@ -308,10 +310,6 @@ async def test_uart_mock_modbus_server_controller_write( @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, run_compiled: RunCompiledFunction, From 1bd937d89c91050a9f77a735b2d31f54e1e7d327 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:44:00 -0500 Subject: [PATCH 082/343] [api] Remove pre-1.14 object_id backward-compat code (#17108) --- esphome/components/api/api_connection.cpp | 12 +----------- esphome/components/api/api_connection.h | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2b1458e2aee..acdf24e747e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -375,7 +375,7 @@ void APIConnection::finalize_iterator_sync_() { void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); - size_t max_batch = this->get_max_batch_size_(); + size_t max_batch = MAX_INITIAL_PER_BATCH; while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { iterator.advance(); } @@ -418,16 +418,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // API 1.14+ clients compute object_id client-side from the entity name - // For older clients, we must send object_id for backward compatibility - // See: https://github.com/esphome/backlog/issues/76 - // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then - // Buffer must remain in scope until encode_to_buffer is called - char object_id_buf[OBJECT_ID_MAX_LEN]; - if (!conn->client_supports_api_version(1, 14)) { - msg.object_id = entity->get_object_id_to(object_id_buf); - } - if (entity->has_own_name()) { msg.name = entity->get_name(); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 804cd9ddd15..92f7065730c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -43,10 +43,7 @@ class APIServer; // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending -// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch -// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then -static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id) -static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id) +static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); @@ -481,13 +478,6 @@ class APIConnection final : public APIServerConnectionBase { inline bool check_voice_assistant_api_connection_() const; #endif - // Get the max batch size based on client API version - // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch - // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly - size_t get_max_batch_size_() const { - return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; - } - // Send keepalive ping or disconnect unresponsive client. // Cold path — extracted from loop() to reduce instruction cache pressure. void __attribute__((noinline)) check_keepalive_(uint32_t now); From 21aee91e6799164c39da486f45a7e971dde03496 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:45:03 -0500 Subject: [PATCH 083/343] [web_server] Remove deprecated object ID URL matching (#17113) --- esphome/components/web_server/web_server.cpp | 29 +------------------- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 909a27c81c2..cdb8544fbb4 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -164,36 +164,9 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { } #endif - // Try matching by entity name (new format) + // Match by entity name if (this->id == entity->get_name()) { result.matched = true; - return result; - } - - // Fall back to object_id (deprecated format) - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = entity->get_object_id_to(object_id_buf); - if (this->id == object_id) { - result.matched = true; - // Log deprecation warning -#ifdef USE_DEVICES - Device *device = entity->get_device(); - if (device != nullptr) { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(), - this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(), - this->domain.c_str(), device->get_name(), entity->get_name().c_str()); - } else -#endif - { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(), - (int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str()); - } } return result; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 25f8f8212dd..e4defdbd9a4 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -76,7 +76,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name first, then fall back to object_id with deprecation warning + /// Match entity by name /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; From 7c2603d9bc764c0ff10053b81bf5edc48d9c90eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:47:15 -0500 Subject: [PATCH 084/343] [ethernet] Defer clk_mode removal to 2026.9.0 (#17114) --- esphome/components/ethernet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f6afc30ff23..6af68e4e3c4 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -324,7 +324,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.7.0.", + "Removal scheduled for 2026.9.0.", config[CONF_CLK_MODE], mode, pin, From 03121d2efe6744df4ae3176a40f9c259f1e5162c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:49:27 -0500 Subject: [PATCH 085/343] [core] Remove deprecated std::string GPIOPin::dump_summary() (#17115) --- esphome/core/gpio.h | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index f2f85e18bc9..43db3b7c0c5 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,8 +1,6 @@ #pragma once #include #include -#include -#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -80,11 +78,6 @@ class GPIOPin { /// which may exceed len-1 if truncation occurred (snprintf semantics) virtual size_t dump_summary(char *buffer, size_t len) const; - /// Get a summary of this pin as a string. - /// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0. - ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0") - virtual std::string dump_summary() const; - virtual bool is_internal() { return false; } }; @@ -122,28 +115,14 @@ class InternalGPIOPin : public GPIOPin { virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; }; -// Inline default implementations for GPIOPin virtual methods. -// These provide bridge functionality for backwards compatibility with external components. - -// Default implementation bridges to old std::string method for backwards compatibility. +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len == 0) - return 0; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - std::string s = this->dump_summary(); -#pragma GCC diagnostic pop - size_t copy_len = std::min(s.size(), len - 1); - memcpy(buffer, s.c_str(), copy_len); - buffer[copy_len] = '\0'; - return s.size(); // Return would-be length (snprintf semantics) + if (len > 0) + buffer[0] = '\0'; + return 0; } -// Default implementation returns empty string. -// External components should override this if they haven't migrated to buffer-based version. -// Remove before 2026.7.0 -inline std::string GPIOPin::dump_summary() const { return {}; } - // Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { char buffer[GPIO_SUMMARY_MAX_LEN]; From f273221cf47fb2c80c9883fcb7681591e0977312 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:50:32 -0500 Subject: [PATCH 086/343] [core] Remove deprecated value_accuracy_to_string() (#17116) --- esphome/core/alloc_helpers.cpp | 8 -------- esphome/core/alloc_helpers.h | 8 -------- 2 files changed, 16 deletions(-) diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 11c7abe3f7b..27c50ebb2a6 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -86,14 +86,6 @@ std::string str_sprintf(const char *fmt, ...) { return str; } -// --- Value formatting helpers --- - -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); -} - // --- Base64 helpers --- static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h index fe350886b76..1da3162333b 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -94,14 +94,6 @@ std::string format_hex_pretty(const std::string &data, char separator = '.', boo /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); -// --- Value formatting helpers (allocating) --- - -/// Format a float value with accuracy decimals to a string. -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) -std::string -value_accuracy_to_string(float value, int8_t accuracy_decimals); - // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. From d1d77fc217e51af4291ba63e33a2281ac35e5a79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:51:08 -0500 Subject: [PATCH 087/343] [remote_base] Remove deprecated MideaData::to_string() (#17117) --- esphome/components/remote_base/midea_protocol.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index f21dd40828f..47bad6826fc 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -28,9 +28,6 @@ class MideaData { bool is_valid() const { return this->data_[OFFSET_CS] == this->calc_cs_(); } void finalize() { this->data_[OFFSET_CS] = this->calc_cs_(); } bool is_compliment(const MideaData &rhs) const; - /// @deprecated Allocates heap memory. Use to_str() instead. Removed in 2026.7.0. - ESPDEPRECATED("Allocates heap memory. Use to_str() instead. Removed in 2026.7.0.", "2026.1.0") - std::string to_string() const { return format_hex_pretty(this->data_.data(), this->data_.size()); } // NOLINT /// Buffer size for to_str(): 6 bytes = "AA.BB.CC.DD.EE.FF\0" static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(6); /// Format to buffer, returns pointer to buffer From d8f883bd9d37399c9528ff39f47f6f7d92f85df9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 14:54:05 -0500 Subject: [PATCH 088/343] [core] Remove deprecated get_object_id() and get_compilation_time() (#17112) --- esphome/core/application.h | 9 --------- esphome/core/entity_base.cpp | 7 ------- esphome/core/entity_base.h | 12 ------------ esphome/core/entity_helpers.py | 2 +- tests/unit_tests/core/test_entity_helpers.py | 9 +++++---- 5 files changed, 6 insertions(+), 33 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7c12a66b2cf..76af5145115 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -194,15 +194,6 @@ class Application { /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) void get_build_time_string(std::span buffer); - /// Get the build time as a string (deprecated, use get_build_time_string() instead) - // Remove before 2026.7.0 - ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0") - std::string get_compilation_time() { - char buf[BUILD_TIME_STR_SIZE]; - this->get_build_time_string(buf); - return std::string(buf); - } - /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a47af1dd93c..32135860bba 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -147,13 +147,6 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Entity Object ID - computed on-demand from name -std::string EntityBase::get_object_id() const { - char buf[OBJECT_ID_MAX_LEN]; - size_t len = this->write_object_id_to(buf, sizeof(buf)); - return std::string(buf, len); -} - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2726a92c97a..4f708209d41 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,18 +73,6 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the sanitized name of this Entity as an ID. - // Deprecated: object_id mangles names and all object_id methods are planned for removal. - // See https://github.com/esphome/backlog/issues/76 - // Now is the time to stop using object_id entirely. If you still need it temporarily, - // use get_object_id_to() which will remain available longer but will also eventually be removed. - ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal " - "(see https://github.com/esphome/backlog/issues/76). " - "Now is the time to stop using object_id. If still needed, use get_object_id_to() " - "which will remain available longer. get_object_id() will be removed in 2026.7.0", - "2025.12.0") - std::string get_object_id() const; - // Get the unique Object ID of this Entity uint32_t get_object_id_hash() const { return this->object_id_hash_; } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ff60260280a..38c7f3ca43f 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -337,7 +337,7 @@ def get_base_entity_object_id( This function calculates what object_id_c_str_ should be set to in C++. - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - If !has_own_name && is_name_add_mac_suffix_enabled(): return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - Else: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e79ff850f91..3ac4ce27afe 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None: def test_name_add_mac_suffix_behavior() -> None: """Test behavior related to name_add_mac_suffix. - In C++, when name_add_mac_suffix is enabled and entity has no name, - get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) - dynamically. Our function always returns the same result since we're - calculating the base for duplicate tracking. + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. """ # The function should always return the same result regardless of # name_add_mac_suffix setting, as we're calculating the base object_id From 78c6131bbf1c57eac765507fe274f99c9d716fc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:36 -0500 Subject: [PATCH 089/343] [web_server] Deprecate version 1 (#17109) --- esphome/components/web_server/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index fd380a38dd7..788bedec349 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import gzip +import logging import esphome.codegen as cg from esphome.components import web_server_base @@ -38,6 +39,8 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["json", "web_server_base"] CONF_SORTING_GROUP_ID = "sorting_group_id" @@ -71,6 +74,15 @@ def default_url(config: ConfigType) -> ConfigType: return config +def validate_version_deprecated(config: ConfigType) -> ConfigType: + if config[CONF_VERSION] == 1: + _LOGGER.warning( + "Version 1 of 'web_server' is deprecated and will be removed in " + "2027.1.0. Please migrate to version 2 (the default) or version 3." + ) + return config + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -220,6 +232,7 @@ CONFIG_SCHEMA = cv.All( ] ), default_url, + validate_version_deprecated, validate_local, validate_sorting_groups, validate_ota, From c6ead57a9ef7a74556da54da6e2b0ebd93fac729 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:46 -0500 Subject: [PATCH 090/343] [packages] Remove deprecated single-package include syntax (#17119) --- esphome/components/packages/__init__.py | 61 +------- .../component_tests/packages/test_packages.py | 140 ++---------------- 2 files changed, 18 insertions(+), 183 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index c1c5bd2ae30..44a1ebf36e7 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,7 +1,6 @@ from collections import UserDict from collections.abc import Callable from functools import reduce -import logging from pathlib import Path from typing import Any @@ -36,8 +35,6 @@ from esphome.const import ( ) from esphome.core import EsphomeError -_LOGGER = logging.getLogger(__name__) - DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool: return CONF_URL in package_config -def is_package_definition(value: object) -> bool: - """Returns True if the value looks like a package definition rather than a config fragment. - - Package definitions are IncludeFile objects, git URL shorthand strings, or - remote package dicts (containing a ``url:`` key). Config fragments are - plain dicts that represent component configuration. - """ - return isinstance(value, (yaml_util.IncludeFile, str)) or ( - isinstance(value, dict) and is_remote_package(value) - ) - - def valid_package_contents(package_config: dict) -> dict: """Validate that a package looks like a plausible ESPHome config fragment. @@ -134,22 +119,6 @@ def validate_source_shorthand(value): return REMOTE_PACKAGE_SCHEMA(conf) -def deprecate_single_package(config: dict) -> dict: - _LOGGER.warning( - """ - Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. - This method for including packages will go away in 2026.7.0 - Please use a list instead: - - packages: - - !include mypackage.yaml - - See https://github.com/esphome/esphome/pull/12116 - """ - ) - return config - - REMOTE_PACKAGE_SCHEMA = cv.All( cv.Schema( { @@ -198,10 +167,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: str: PACKAGE_SCHEMA, # a named dict of package definitions, or } ), - [PACKAGE_SCHEMA], # a list of package definitions, or - cv.All( # a single package definition (deprecated) - cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package - ), + [PACKAGE_SCHEMA], # a list of package definitions ) @@ -348,7 +314,6 @@ def _walk_packages( config: dict, callback: PackageCallback, context: ContextVars | None = None, - validate_deprecated: bool = True, path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -378,17 +343,7 @@ def _walk_packages( elif ( result := _walk_package_dict(packages, callback, context, packages_path) ) is not None: - if not validate_deprecated or any( - is_package_definition(v) for v in packages.values() - ): - raise result - # Fallback: treat the dict as a single deprecated package. - # This block can be removed once the single-package - # deprecation period (2026.7.0) is over. - config[CONF_PACKAGES] = [packages] - return _walk_packages( - deprecate_single_package(config), callback, context, path=path - ) + raise result config[CONF_PACKAGES] = packages return config @@ -588,9 +543,6 @@ class _PackageProcessor: path: yaml_util.DocumentPath, ) -> dict: """Resolve a single package and recurse into any nested packages.""" - from_remote = isinstance(package_config, dict) and is_remote_package( - package_config - ) package_config = self.resolve_package(package_config, context_vars, path) context_vars = self.collect_substitutions(package_config, context_vars) @@ -600,17 +552,10 @@ class _PackageProcessor: # Push context from !include vars on the packages key (the package root # was already pushed in collect_substitutions above). context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - # Disable the deprecated single-package fallback for remote - # packages. _process_remote_package returns dicts with - # already-resolved values that is_package_definition cannot - # distinguish from config fragments, so the fallback would - # always fire and mask real errors with wrong paths - # (packages->0 instead of packages->). return _walk_packages( package_config, self.process_package, context_vars, - validate_deprecated=not from_remote, path=path, ) @@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict: merge_list.append(package_config) return _walk_packages(package_config, process_package_callback, path=path) - _walk_packages(config, process_package_callback, validate_deprecated=False) + _walk_packages(config, process_package_callback) # Merge all packages into the main config: config = reduce(lambda new, old: merge_config(old, new), merge_list, config) del config[CONF_PACKAGES] diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 66f946a5bde..6990c1c051d 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -12,7 +12,6 @@ from esphome.components.packages import ( _substitute_package_definition, _walk_packages, do_packages_pass, - is_package_definition, merge_packages, resolve_packages, ) @@ -89,44 +88,6 @@ def packages_pass(config): return config -_INCLUDE_FILE = "INCLUDE_FILE" - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - # IncludeFile objects are package definitions - (_INCLUDE_FILE, True), - # Git URL shorthand strings are package definitions - ("github://esphome/firmware/base.yaml@main", True), - # Remote package dicts (with url key) are package definitions - ({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True), - # Plain config dicts are NOT package definitions (they are config fragments) - ({"wifi": {"ssid": "test"}}, False), - # None is not a package definition - (None, False), - # Lists are not package definitions - ([{"wifi": {"ssid": "test"}}], False), - # Empty dicts are not package definitions - ({}, False), - ], - ids=[ - "include_file", - "git_shorthand", - "remote_package", - "config_fragment", - "none", - "list", - "empty_dict", - ], -) -def test_is_package_definition(value: object, expected: bool) -> None: - """Test that is_package_definition correctly identifies package definitions.""" - if value is _INCLUDE_FILE: - value = MagicMock(spec=IncludeFile) - assert is_package_definition(value) is expected - - def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. @@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None: assert actual == expected -def test_single_package( - basic_esphome, - basic_wifi, - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Tests the simple case where a single package is added to the top-level config as is. - In this test, the CONF_WIFI config is expected to be simply added to the top-level config. - This tests the case where the user just put packages: !include package.yaml, not - part of a list or mapping of packages. - This behavior is deprecated, the test also checks if a warning is issued. - """ - config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}} - - expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi} - - with caplog.at_level("WARNING"): - actual = packages_pass(config) - - assert actual == expected - - assert "This method for including packages will go away in 2026.7.0" in caplog.text - - def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises( 6, "some string", True, + None, + ["some string"], + {"some_component": 8}, + {3: 2}, ], ) def test_invalid_package_contents_rejected(invalid_package: object) -> None: @@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None: do_packages_pass(config) -@pytest.mark.xfail( - reason="Deprecated single-package fallback swallows these errors. " - "Remove xfail when single-package deprecation is removed (2026.7.0).", - strict=True, -) -@pytest.mark.parametrize( - "invalid_package", - [ - None, - ["some string"], - {"some_component": 8}, - {3: 2}, - ], -) -def test_invalid_package_contents_masked_by_deprecation( - invalid_package: object, -) -> None: - """These invalid packages are swallowed by the deprecated single-package fallback.""" +def test_single_package_fragment_form_rejected() -> None: + """The deprecated single-package form is removed and now raises. + + Previously ``packages: !include some_package.yaml`` resolving to a bare config + fragment dict was silently wrapped and merged via the single-package fallback. + That form must now raise instead of being accepted. + """ config = { - CONF_PACKAGES: { - "some_package": invalid_package, - }, + CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}}, } with pytest.raises(cv.Invalid): do_packages_pass(config) @@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning( assert "deprecated" not in caplog.text.lower() -def test_validate_deprecated_false_raises_directly( +def test_named_package_errors_raise_directly( caplog: pytest.LogCaptureFixture, ) -> None: - """With validate_deprecated=False, errors raise directly without fallback. - - This is the codepath used for remote packages where _process_remote_package - returns already-resolved dicts that is_package_definition cannot detect. - """ + """Errors processing a named-dict package raise directly, with no deprecation warning.""" config = { CONF_PACKAGES: { "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, @@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly( caplog.at_level(logging.WARNING), pytest.raises(cv.Invalid, match="nested error"), ): - _walk_packages(config, failing_callback, validate_deprecated=False) + _walk_packages(config, failing_callback) assert "deprecated" not in caplog.text.lower() @@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None: _walk_packages(config, fail_on_last) -def test_deprecated_single_package_fallback_still_works( - caplog: pytest.LogCaptureFixture, -) -> None: - """The deprecated single-package form still falls back at the top level. - - When a dict's values are plain config fragments (not package definitions) - and the callback fails, the deprecated fallback wraps the dict in a list - and retries with a deprecation warning. - """ - config = { - CONF_PACKAGES: { - CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}, - }, - } - - attempt = 0 - - def fail_then_succeed( - package_config: dict, context: object, path: DocumentPath | None = None - ) -> dict: - nonlocal attempt - attempt += 1 - if attempt == 1: - # First attempt: treating as named dict fails - raise cv.Invalid("not a valid package") - # Second attempt: after fallback wraps as list, succeeds - return package_config - - with caplog.at_level(logging.WARNING): - _walk_packages(config, fail_then_succeed) - - assert "deprecated" in caplog.text.lower() - - def test_merge_packages_invalid_nested_type_raises() -> None: """Invalid nested packages type during merge raises cv.Invalid.""" config = { From 921758f87dcdaf12cb40d12f1c5443094b5fc4e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 15:00:55 -0500 Subject: [PATCH 091/343] [core] Clarify resolve error when a device has no network log/OTA transport (#17107) --- esphome/__main__.py | 39 +++++++++++++++++++----- tests/unit_tests/test_main.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bda3dcbd05a..680de02201f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -268,6 +268,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: return _resolve_with_cache(CORE.address, purpose) +def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: + """Build the error when a default device target produced no usable host. + + When the OTA default was requested and the address resolves but the config + lacks the transport the purpose needs (``api:`` for logs, an ``ota:`` + platform for uploads), name that gap instead of the misleading + "could not be resolved" / set-use_address hint. + """ + if "OTA" in defaults and has_resolvable_address(): + if purpose == Purpose.LOGGING and not has_api(): + return ( + "Cannot view logs over the network: no 'api:' component is " + "configured. Network log streaming requires the native API; add " + "an 'api:' component, enable MQTT logging, or view logs over USB." + ) + if purpose == Purpose.UPLOADING and not has_ota(): + return ( + "Cannot upload over the network: no 'ota:' platform is " + "configured. Add an 'ota:' platform, or upload over USB." + ) + if CORE.dashboard: + hint = "If you know the IP, set 'use_address' in your network config." + else: + hint = "If you know the IP, try --device " + return ( + f"All specified devices {defaults} could not be resolved. " + f"Is the device connected to the network? {hint}" + ) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -317,14 +347,7 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - if CORE.dashboard: - hint = "If you know the IP, set 'use_address' in your network config." - else: - hint = "If you know the IP, try --device " - raise EsphomeError( - f"All specified devices {defaults} could not be resolved. " - f"Is the device connected to the network? {hint}" - ) + raise EsphomeError(_unresolved_default_error(purpose, defaults)) return resolved # No devices specified, show interactive chooser diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index acd39cedc62..bb06b6c930d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, @@ -713,9 +714,7 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") - with pytest.raises( - EsphomeError, match="All specified devices .* could not be resolved" - ): + with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"): choose_upload_log_host( default="OTA", check_default=None, @@ -735,6 +734,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: + """A resolvable device with only ota: fails logs with a missing-api message.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None: + """A resolvable device with neither api: nor MQTT logging fails clearly.""" + setup_core(address="192.168.1.100") + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: + """A .local host with mDNS disabled and no cache keeps the dashboard hint.""" + setup_core( + config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}}, + address="esp32-a1s.local", + ) + CORE.dashboard = True + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "could not be resolved" in msg + assert "set 'use_address'" in msg + + +def test_unresolved_default_error_upload_with_ota_is_generic() -> None: + """With ota: present the upload error stays generic, not transport-specific.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + CORE.dashboard = False + + msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"]) + assert "could not be resolved" in msg + assert "try --device " in msg + + @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" From 036768c399ae88c0c37b2ac0cf1d26f6b72538f6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 21 Jun 2026 16:19:13 -0400 Subject: [PATCH 092/343] [audio] Fix mono channel MP3 playback (#17106) --- esphome/components/audio/audio_decoder.cpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index f709c23fb67..fe9ad9c9add 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -326,14 +326,8 @@ FileDecoderState AudioDecoder::decode_mp3_() { } else if (result == micro_mp3::MP3_NEED_MORE_DATA) { return FileDecoderState::MORE_TO_PROCESS; } else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) { - // Reallocate to decode the frame on the next call - if (this->mp3_decoder_->get_channels() > 0) { - this->free_buffer_required_ = - this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t); - } else { - // Fallback to worst-case size if channel info isn't available - this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); - } + // Fallback to worst-case size + this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { return FileDecoderState::FAILED; } From 6c10fc1272ae17befe1f4b1275918f73791a1455 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:21:50 -0500 Subject: [PATCH 093/343] [hub75] Remove deprecated scan_wiring name aliases (#17118) --- esphome/components/hub75/display.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 0d1b87941de..a404fbbade1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -1,4 +1,3 @@ -import logging from typing import Any from esphome import automation, pins @@ -27,8 +26,6 @@ from esphome.types import ConfigType from . import boards, hub75_ns -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["esp32"] CODEOWNERS = ["@stuartparmenter"] @@ -133,30 +130,11 @@ SCAN_WIRINGS = { "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, } -# Deprecated scan wiring names - mapped to new names -DEPRECATED_SCAN_WIRINGS = { - "FOUR_SCAN_16PX_HIGH": "SCAN_1_4_16PX_HIGH", - "FOUR_SCAN_32PX_HIGH": "SCAN_1_8_32PX_HIGH", - "FOUR_SCAN_64PX_HIGH": "SCAN_1_8_64PX_HIGH", -} - def _validate_scan_wiring(value): - """Validate scan_wiring with deprecation warnings for old names.""" + """Validate scan_wiring against the allowed names.""" value = cv.string(value).upper().replace(" ", "_") - # Check if using deprecated name - # Remove deprecated names in 2026.7.0 - if value in DEPRECATED_SCAN_WIRINGS: - new_name = DEPRECATED_SCAN_WIRINGS[value] - _LOGGER.warning( - "Scan wiring '%s' is deprecated and will be removed in ESPHome 2026.7.0. " - "Please use '%s' instead.", - value, - new_name, - ) - value = new_name - # Validate against allowed values if value not in SCAN_WIRINGS: raise cv.Invalid( From c4abc5476e11acfe3e81bbe4b3894bb881a34a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:22:08 -0500 Subject: [PATCH 094/343] [core] Remove deprecated std::string scheduler/timer overloads (#17111) --- esphome/core/component.cpp | 40 --- esphome/core/component.h | 78 ++---- esphome/core/scheduler.cpp | 17 -- esphome/core/scheduler.h | 16 +- .../scheduler_bulk_cleanup_component.cpp | 19 +- .../rapid_cancellation_component.cpp | 14 +- .../simultaneous_callbacks_component.cpp | 14 +- .../__init__.py | 21 -- .../string_lifetime_component.cpp | 260 ------------------ .../string_lifetime_component.h | 35 --- .../__init__.py | 21 -- .../string_name_stress_component.cpp | 108 -------- .../string_name_stress_component.h | 20 -- .../integration/fixtures/scheduler_pool.yaml | 12 +- .../fixtures/scheduler_string_lifetime.yaml | 48 ---- .../scheduler_string_name_stress.yaml | 39 --- .../fixtures/scheduler_string_test.yaml | 42 ++- .../test_scheduler_string_lifetime.py | 169 ------------ .../test_scheduler_string_name_stress.py | 116 -------- .../integration/test_scheduler_string_test.py | 2 +- 20 files changed, 74 insertions(+), 1017 deletions(-) delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp delete mode 100644 tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h delete mode 100644 tests/integration/fixtures/scheduler_string_lifetime.yaml delete mode 100644 tests/integration/fixtures/scheduler_string_name_stress.yaml delete mode 100644 tests/integration/test_scheduler_string_lifetime.py delete mode 100644 tests/integration/test_scheduler_string_name_stress.py diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 7ef5ff50a53..281d7aaecdb 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,24 +85,10 @@ void Component::setup() {} void Component::loop() {} -void Component::set_interval(const std::string &name, uint32_t interval, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_interval(this, name, interval, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_interval(const char *name, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, name, interval, std::move(f)); } -bool Component::cancel_interval(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_interval(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } @@ -137,24 +123,10 @@ bool Component::cancel_retry(const char *name) { // NOLINT #pragma GCC diagnostic pop } -void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, timeout, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } -bool Component::cancel_timeout(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_timeout(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } @@ -319,21 +291,9 @@ void Component::reset_to_construction_state() { void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } -bool Component::cancel_defer(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} bool Component::cancel_defer(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } -void Component::defer(const std::string &name, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, 0, std::move(f)); -#pragma GCC diagnostic pop -} void Component::defer(const char *name, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, 0, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 299a5f72eaa..1ae70371a19 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -357,9 +357,9 @@ class Component { /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); - /** Set an interval function with a unique name. Empty name means no cancelling possible. + /** Set an interval function with a const char* name. Empty name means no cancelling possible. * - * This will call f every interval ms. Can be cancelled via CancelInterval(). + * This will call f every interval ms. Can be cancelled via cancel_interval(). * Similar to javascript's setInterval(). * * IMPORTANT NOTE: @@ -372,18 +372,6 @@ class Component { * * Note also that the first call to f will not happen immediately, but after a random delay. This is * intended to prevent many interval functions from being called at the same time. - * - * @param name The identifier for this interval function. - * @param interval The interval in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_interval() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(const std::string &name, uint32_t interval, std::function &&f); // NOLINT - - /** Set an interval function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -391,7 +379,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the scheduled task * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The identifier for this interval function (must have static lifetime) * @param interval The interval in ms @@ -416,12 +404,9 @@ class Component { * @param name The identifier for this interval function. * @return Whether an interval functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(const std::string &name); // NOLINT - bool cancel_interval(const char *name); // NOLINT - bool cancel_interval(uint32_t id); // NOLINT - bool cancel_interval(InternalSchedulerID id); // NOLINT + bool cancel_interval(const char *name); // NOLINT + bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -458,25 +443,13 @@ class Component { ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a unique name. + /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. * * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delay. If you need exact timing please + * loop() and therefore can be significantly delayed. If you need exact timing please * use hardware timers. - * - * @param name The identifier for this timeout function. - * @param timeout The timeout in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_timeout() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(const std::string &name, uint32_t timeout, std::function &&f); // NOLINT - - /** Set a timeout function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -484,7 +457,9 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the timeout duration * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. + * + * @see cancel_timeout() * * @param name The identifier for this timeout function (must have static lifetime) * @param timeout The timeout in ms @@ -509,25 +484,13 @@ class Component { * @param name The identifier for this timeout function. * @return Whether a timeout functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(const std::string &name); // NOLINT - bool cancel_timeout(const char *name); // NOLINT - bool cancel_timeout(uint32_t id); // NOLINT - bool cancel_timeout(InternalSchedulerID id); // NOLINT - - /** Defer a callback to the next loop() call. - * - * If name is specified and a defer() object with the same name exists, the old one is first removed. - * - * @param name The name of the defer function. - * @param f The callback. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - void defer(const std::string &name, std::function &&f); // NOLINT + bool cancel_timeout(const char *name); // NOLINT + bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call with a const char* name. + * + * If name is specified and a defer() object with the same name exists, the old one is first removed. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the deferred task. * This means the name should be: @@ -535,7 +498,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the deferred execution * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The name of the defer function (must have static lifetime) * @param f The callback @@ -549,11 +512,8 @@ class Component { void defer(uint32_t id, std::function &&f); // NOLINT /// Cancel a defer callback using the specified name, name must not be empty. - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_defer(const std::string &name); // NOLINT - bool cancel_defer(const char *name); // NOLINT - bool cancel_defer(uint32_t id); // NOLINT + bool cancel_defer(const char *name); // NOLINT + bool cancel_defer(uint32_t id); // NOLINT void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 15bb9ea2398..9c5557bdfce 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -254,30 +254,16 @@ void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); -} void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } -bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); -} bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); } bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } -void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); -} - void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, @@ -287,9 +273,6 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } -bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); -} bool HOT Scheduler::cancel_interval(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 378c0fb94b7..c7743e5b2af 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -31,11 +31,6 @@ class Scheduler { template friend class DelayAction; public: - // std::string overload - deprecated, use const char* or uint32_t instead - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function &&func); - /** Set a timeout with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -53,8 +48,6 @@ class Scheduler { static_cast(id), timeout, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); bool cancel_timeout(Component *component, InternalSchedulerID id) { @@ -62,9 +55,6 @@ class Scheduler { SchedulerItem::TIMEOUT); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(Component *component, const std::string &name, uint32_t interval, std::function &&func); - /** Set an interval with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -82,8 +72,6 @@ class Scheduler { static_cast(id), interval, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); bool cancel_interval(Component *component, InternalSchedulerID id) { @@ -396,8 +384,8 @@ class Scheduler { inline bool HOT names_match_static_(const char *name1, const char *name2) const { // Check pointer equality first (common for static strings), then string contents // The core ESPHome codebase uses static strings (const char*) for component names, - // making pointer comparison effective. The std::string overloads exist only for - // compatibility with external components but are rarely used in practice. + // making pointer comparison effective. The strcmp fallback covers distinct pointers + // with identical content (e.g. names built into separate static buffers). return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); } diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp index f6fd1b1de72..d419694af76 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp @@ -8,14 +8,23 @@ static const char *const TAG = "bulk_cleanup"; void SchedulerBulkCleanupComponent::setup() { ESP_LOGI(TAG, "Scheduler bulk cleanup test component loaded"); } +// Static name tables keep the const char* pointers valid for the lifetime of the scheduled tasks. +static const char *const BULK_TIMEOUT_NAMES[25] = { + "bulk_timeout_0", "bulk_timeout_1", "bulk_timeout_2", "bulk_timeout_3", "bulk_timeout_4", + "bulk_timeout_5", "bulk_timeout_6", "bulk_timeout_7", "bulk_timeout_8", "bulk_timeout_9", + "bulk_timeout_10", "bulk_timeout_11", "bulk_timeout_12", "bulk_timeout_13", "bulk_timeout_14", + "bulk_timeout_15", "bulk_timeout_16", "bulk_timeout_17", "bulk_timeout_18", "bulk_timeout_19", + "bulk_timeout_20", "bulk_timeout_21", "bulk_timeout_22", "bulk_timeout_23", "bulk_timeout_24"}; +static const char *const POST_CLEANUP_NAMES[5] = {"post_cleanup_0", "post_cleanup_1", "post_cleanup_2", + "post_cleanup_3", "post_cleanup_4"}; + void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { ESP_LOGI(TAG, "Starting bulk cleanup test..."); // Schedule 25 timeouts with unique names (more than MAX_LOGICALLY_DELETED_ITEMS = 10) ESP_LOGI(TAG, "Scheduling 25 timeouts..."); for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 2500, [i]() { + App.scheduler.set_timeout(this, BULK_TIMEOUT_NAMES[i], 2500, [i]() { // These should never execute as we'll cancel them ESP_LOGW(TAG, "Timeout %d executed - this should not happen!", i); }); @@ -24,8 +33,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Cancel all of them to mark for removal ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup..."); int cancelled_count = 0; - for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); + for (const char *name : BULK_TIMEOUT_NAMES) { if (App.scheduler.cancel_timeout(this, name)) { cancelled_count++; } @@ -56,8 +64,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Also schedule some normal timeouts to ensure scheduler keeps working after cleanup static int post_cleanup_count = 0; for (int i = 0; i < 5; i++) { - std::string name = "post_cleanup_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 50 + i * 25, [i]() { + App.scheduler.set_timeout(this, POST_CLEANUP_NAMES[i], 50 + i * 25, [i]() { ESP_LOGI(TAG, "Post-cleanup timeout %d executed correctly", i); post_cleanup_count++; if (post_cleanup_count >= 5) { diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp index 0e5525d2656..4971a15dbc5 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp @@ -4,12 +4,18 @@ #include #include #include -#include namespace esphome::scheduler_rapid_cancellation_component { static const char *const TAG = "scheduler_rapid_cancellation"; +// Static name table keeps the const char* pointers valid for the lifetime of the scheduled tasks. +// Threads race over this fixed set of names; STATIC_STRING names match by content, so scheduling +// the same name replaces (implicitly cancels) the previous timeout, exactly as before. +static const char *const SHARED_TIMEOUT_NAMES[10] = { + "shared_timeout_0", "shared_timeout_1", "shared_timeout_2", "shared_timeout_3", "shared_timeout_4", + "shared_timeout_5", "shared_timeout_6", "shared_timeout_7", "shared_timeout_8", "shared_timeout_9"}; + void SchedulerRapidCancellationComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerRapidCancellationComponent setup"); } void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { @@ -32,14 +38,12 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { for (int i = 0; i < OPERATIONS_PER_THREAD; i++) { // Use modulo to ensure multiple threads use the same names int name_index = i % NUM_NAMES; - std::stringstream ss; - ss << "shared_timeout_" << name_index; - std::string name = ss.str(); + const char *name = SHARED_TIMEOUT_NAMES[name_index]; // All threads schedule timeouts - this will implicitly cancel existing ones this->set_timeout(name, 150, [this, name]() { this->total_executed_.fetch_add(1); - ESP_LOGI(TAG, "Executed callback '%s'", name.c_str()); + ESP_LOGI(TAG, "Executed callback '%s'", name); }); this->total_scheduled_.fetch_add(1); diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp index a817b9f508f..a3d135527fe 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp @@ -1,9 +1,9 @@ #include "simultaneous_callbacks_component.h" #include "esphome/core/log.h" +#include #include #include #include -#include namespace esphome::scheduler_simultaneous_callbacks_component { @@ -41,13 +41,11 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() std::this_thread::sleep_until(start_time + std::chrono::microseconds(100)); for (int i = 0; i < CALLBACKS_PER_THREAD; i++) { - // Create unique name for each callback - std::stringstream ss; - ss << "thread_" << thread_id << "_cb_" << i; - std::string name = ss.str(); + // Unique numeric ID for each callback (zero heap allocation, no name collisions) + uint32_t callback_id = static_cast(thread_id) * CALLBACKS_PER_THREAD + i; // Schedule callback for exactly DELAY_MS from now - this->set_timeout(name, DELAY_MS, [this, name]() { + this->set_timeout(callback_id, DELAY_MS, [this, callback_id]() { // Increment concurrent counter atomically int current = this->callbacks_at_once_.fetch_add(1) + 1; @@ -57,7 +55,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() // Loop until we successfully update or someone else set a higher value } - ESP_LOGV(TAG, "Callback executed: %s (concurrent: %d)", name.c_str(), current); + ESP_LOGV(TAG, "Callback executed: id=%" PRIu32 " (concurrent: %d)", callback_id, current); // Simulate some minimal work std::atomic work{0}; @@ -73,7 +71,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() }); this->total_scheduled_.fetch_add(1); - ESP_LOGV(TAG, "Scheduled callback %s", name.c_str()); + ESP_LOGV(TAG, "Scheduled callback id=%" PRIu32, callback_id); } ESP_LOGD(TAG, "Thread %d completed scheduling", thread_id); diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py deleted file mode 100644 index 3f29a839ef9..00000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_lifetime_component_ns = cg.esphome_ns.namespace( - "scheduler_string_lifetime_component" -) -SchedulerStringLifetimeComponent = scheduler_string_lifetime_component_ns.class_( - "SchedulerStringLifetimeComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringLifetimeComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp deleted file mode 100644 index cc1b9f78147..00000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include "string_lifetime_component.h" -#include "esphome/core/log.h" -#include -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -static const char *const TAG = "scheduler_string_lifetime"; - -void SchedulerStringLifetimeComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringLifetimeComponent setup"); } - -void SchedulerStringLifetimeComponent::run_string_lifetime_test() { - ESP_LOGI(TAG, "Starting string lifetime tests"); - - this->tests_passed_ = 0; - this->tests_failed_ = 0; - - // Run each test - test_temporary_string_lifetime(); - test_scope_exit_string(); - test_vector_reallocation(); - test_string_move_semantics(); - test_lambda_capture_lifetime(); -} - -void SchedulerStringLifetimeComponent::run_test1() { - test_temporary_string_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test1_complete", 10, []() { ESP_LOGI(TAG, "Test 1 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test2() { - test_scope_exit_string(); - // Wait for all callbacks to execute - this->set_timeout("test2_complete", 20, []() { ESP_LOGI(TAG, "Test 2 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test3() { - test_vector_reallocation(); - // Wait for all callbacks to execute - this->set_timeout("test3_complete", 60, []() { ESP_LOGI(TAG, "Test 3 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test4() { - test_string_move_semantics(); - // Wait for all callbacks to execute - this->set_timeout("test4_complete", 35, []() { ESP_LOGI(TAG, "Test 4 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test5() { - test_lambda_capture_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test5_complete", 50, []() { ESP_LOGI(TAG, "Test 5 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_final_check() { - ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); - ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); - - if (this->tests_failed_ == 0) { - ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!"); - } else { - ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); - } - ESP_LOGI(TAG, "String lifetime tests complete"); -} - -void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() { - ESP_LOGI(TAG, "Test 1: Temporary string lifetime for timeout names"); - - // Test with a temporary string that goes out of scope immediately - { - std::string temp_name = "temp_callback_" + std::to_string(12345); - - // Schedule with temporary string name - scheduler must copy/store this - this->set_timeout(temp_name, 1, [this]() { - ESP_LOGD(TAG, "Callback for temp string name executed"); - this->tests_passed_++; - }); - - // String goes out of scope here, but scheduler should have made a copy - } - - // Test with rvalue string as name - this->set_timeout(std::string("rvalue_test"), 2, [this]() { - ESP_LOGD(TAG, "Rvalue string name callback executed"); - this->tests_passed_++; - }); - - // Test cancelling with reconstructed string - { - std::string cancel_name = "cancel_test_" + std::to_string(999); - this->set_timeout(cancel_name, 100, [this]() { - ESP_LOGE(TAG, "This should have been cancelled!"); - this->tests_failed_++; - }); - } // cancel_name goes out of scope - - // Reconstruct the same string to cancel - std::string cancel_name_2 = "cancel_test_" + std::to_string(999); - bool cancelled = this->cancel_timeout(cancel_name_2); - if (cancelled) { - ESP_LOGD(TAG, "Successfully cancelled with reconstructed string"); - this->tests_passed_++; - } else { - ESP_LOGE(TAG, "Failed to cancel with reconstructed string"); - this->tests_failed_++; - } -} - -void SchedulerStringLifetimeComponent::test_scope_exit_string() { - ESP_LOGI(TAG, "Test 2: Scope exit string names"); - - // Create string names in a limited scope - { - std::string scoped_name = "scoped_timeout_" + std::to_string(555); - - // Schedule with scoped string name - this->set_timeout(scoped_name, 3, [this]() { - ESP_LOGD(TAG, "Scoped name callback executed"); - this->tests_passed_++; - }); - - // scoped_name goes out of scope here - } - - // Test with dynamically allocated string name - { - auto *dynamic_name = new std::string("dynamic_timeout_" + std::to_string(777)); - - this->set_timeout(*dynamic_name, 4, [this, dynamic_name]() { - ESP_LOGD(TAG, "Dynamic string name callback executed"); - this->tests_passed_++; - delete dynamic_name; // Clean up in callback - }); - - // Pointer goes out of scope but string object remains until callback - } - - // Test multiple timeouts with same dynamically created name - for (int i = 0; i < 3; i++) { - std::string loop_name = "loop_timeout_" + std::to_string(i); - this->set_timeout(loop_name, 5 + i * 1, [this, i]() { - ESP_LOGD(TAG, "Loop timeout %d executed", i); - this->tests_passed_++; - }); - // loop_name destroyed and recreated each iteration - } -} - -void SchedulerStringLifetimeComponent::test_vector_reallocation() { - ESP_LOGI(TAG, "Test 3: Vector reallocation stress on timeout names"); - - // Create a vector that will reallocate - std::vector names; - names.reserve(2); // Small initial capacity to force reallocation - - // Schedule callbacks with string names from vector - for (int i = 0; i < 10; i++) { - names.push_back("vector_cb_" + std::to_string(i)); - // Use the string from vector as timeout name - this->set_timeout(names.back(), 8 + i * 1, [this, i]() { - ESP_LOGV(TAG, "Vector name callback %d executed", i); - this->tests_passed_++; - }); - } - - // Force reallocation by adding more elements - // This will move all strings to new memory locations - for (int i = 10; i < 50; i++) { - names.push_back("realloc_trigger_" + std::to_string(i)); - } - - // Add more timeouts after reallocation to ensure old names still work - for (int i = 50; i < 55; i++) { - names.push_back("post_realloc_" + std::to_string(i)); - this->set_timeout(names.back(), 20 + (i - 50), [this]() { - ESP_LOGV(TAG, "Post-reallocation callback executed"); - this->tests_passed_++; - }); - } - - // Clear the vector while timeouts are still pending - names.clear(); - ESP_LOGD(TAG, "Vector cleared - all string names destroyed"); -} - -void SchedulerStringLifetimeComponent::test_string_move_semantics() { - ESP_LOGI(TAG, "Test 4: String move semantics for timeout names"); - - // Test moving string names - std::string original = "move_test_original"; - std::string moved = std::move(original); - - // Schedule with moved string as name - this->set_timeout(moved, 30, [this]() { - ESP_LOGD(TAG, "Moved string name callback executed"); - this->tests_passed_++; - }); - - // original is now empty, try to use it as a different timeout name - original = "reused_after_move"; - this->set_timeout(original, 32, [this]() { - ESP_LOGD(TAG, "Reused string name callback executed"); - this->tests_passed_++; - }); -} - -void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() { - ESP_LOGI(TAG, "Test 5: Complex timeout name scenarios"); - - // Test scheduling with name built in lambda - [this]() { - std::string lambda_name = "lambda_built_name_" + std::to_string(888); - this->set_timeout(lambda_name, 38, [this]() { - ESP_LOGD(TAG, "Lambda-built name callback executed"); - this->tests_passed_++; - }); - }(); // Lambda executes and lambda_name is destroyed - - // Test with shared_ptr name - auto shared_name = std::make_shared("shared_ptr_timeout"); - this->set_timeout(*shared_name, 40, [this, shared_name]() { - ESP_LOGD(TAG, "Shared_ptr name callback executed"); - this->tests_passed_++; - }); - shared_name.reset(); // Release the shared_ptr - - // Test overwriting timeout with same name - std::string overwrite_name = "overwrite_test"; - this->set_timeout(overwrite_name, 1000, [this]() { - ESP_LOGE(TAG, "This should have been overwritten!"); - this->tests_failed_++; - }); - - // Overwrite with shorter timeout - this->set_timeout(overwrite_name, 42, [this]() { - ESP_LOGD(TAG, "Overwritten timeout executed"); - this->tests_passed_++; - }); - - // Test very long string name - std::string long_name; - for (int i = 0; i < 100; i++) { - long_name += "very_long_timeout_name_segment_" + std::to_string(i) + "_"; - } - this->set_timeout(long_name, 44, [this]() { - ESP_LOGD(TAG, "Very long name timeout executed"); - this->tests_passed_++; - }); - - // Test empty string as name - this->set_timeout("", 46, [this]() { - ESP_LOGD(TAG, "Empty string name timeout executed"); - this->tests_passed_++; - }); -} - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h deleted file mode 100644 index 20185f128d1..00000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -class SchedulerStringLifetimeComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_lifetime_test(); - - // Individual test methods exposed as services - void run_test1(); - void run_test2(); - void run_test3(); - void run_test4(); - void run_test5(); - void run_final_check(); - - private: - void test_temporary_string_lifetime(); - void test_scope_exit_string(); - void test_vector_reallocation(); - void test_string_move_semantics(); - void test_lambda_capture_lifetime(); - - int tests_passed_{0}; - int tests_failed_{0}; -}; - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py deleted file mode 100644 index 6cc564395cd..00000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_name_stress_component_ns = cg.esphome_ns.namespace( - "scheduler_string_name_stress_component" -) -SchedulerStringNameStressComponent = scheduler_string_name_stress_component_ns.class_( - "SchedulerStringNameStressComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringNameStressComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp deleted file mode 100644 index 677d371f255..00000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "string_name_stress_component.h" -#include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include - -namespace esphome::scheduler_string_name_stress_component { - -static const char *const TAG = "scheduler_string_name_stress"; - -void SchedulerStringNameStressComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringNameStressComponent setup"); } - -void SchedulerStringNameStressComponent::run_string_name_stress_test() { - // Use member variables to reset state - this->total_callbacks_ = 0; - this->executed_callbacks_ = 0; - static constexpr int NUM_THREADS = 10; - static constexpr int CALLBACKS_PER_THREAD = 100; - - ESP_LOGI(TAG, "Starting string name stress test - multi-threaded set_timeout with std::string names"); - ESP_LOGI(TAG, "This test specifically uses dynamic string names to test memory management"); - - // Track start time - auto start_time = std::chrono::steady_clock::now(); - - // Create threads - std::vector threads; - - ESP_LOGI(TAG, "Creating %d threads, each will schedule %d callbacks with dynamic names", NUM_THREADS, - CALLBACKS_PER_THREAD); - - threads.reserve(NUM_THREADS); - for (int i = 0; i < NUM_THREADS; i++) { - threads.emplace_back([this, i]() { - ESP_LOGV(TAG, "Thread %d starting", i); - - // Each thread schedules callbacks with dynamically created string names - for (int j = 0; j < CALLBACKS_PER_THREAD; j++) { - int callback_id = this->total_callbacks_.fetch_add(1); - - // Create a dynamic string name - this will test memory management - std::stringstream ss; - ss << "thread_" << i << "_callback_" << j << "_id_" << callback_id; - std::string dynamic_name = ss.str(); - - ESP_LOGV(TAG, "Thread %d scheduling timeout with dynamic name: %s", i, dynamic_name.c_str()); - - // Capture necessary values for the lambda - auto *component = this; - - // Schedule with std::string name - this tests the string overload - // Use varying delays to stress the heap scheduler - uint32_t delay = 1 + (callback_id % 50); - - // Also test nested scheduling from callbacks - if (j % 10 == 0) { - // Every 10th callback schedules another callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d (nested scheduler)", callback_id); - - // Schedule another timeout from within this callback with a new dynamic name - std::string nested_name = "nested_from_" + std::to_string(callback_id); - component->set_timeout(nested_name, 1, [callback_id]() { - ESP_LOGV(TAG, "Executed nested string-named callback from %d", callback_id); - }); - }); - } else { - // Regular callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d", callback_id); - }); - } - - // Add some timing variations to increase race conditions - if (j % 5 == 0) { - std::this_thread::sleep_for(std::chrono::microseconds(100)); - } - } - ESP_LOGV(TAG, "Thread %d finished scheduling", i); - }); - } - - // Wait for all threads to complete scheduling - for (auto &t : threads) { - t.join(); - } - - auto end_time = std::chrono::steady_clock::now(); - auto thread_time = std::chrono::duration_cast(end_time - start_time).count(); - ESP_LOGI(TAG, "All threads finished scheduling in %lldms. Created %d callbacks with dynamic names", thread_time, - this->total_callbacks_.load()); - - // Give some time for callbacks to execute - ESP_LOGI(TAG, "Waiting for callbacks to execute..."); - - // Schedule a final callback to signal completion - this->set_timeout("test_complete", 2000, [this]() { - ESP_LOGI(TAG, "String name stress test complete. Executed %d of %d callbacks", this->executed_callbacks_.load(), - this->total_callbacks_.load()); - }); -} - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h deleted file mode 100644 index 121bda62041..00000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include - -namespace esphome::scheduler_string_name_stress_component { - -class SchedulerStringNameStressComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_name_stress_test(); - - private: - std::atomic total_callbacks_{0}; - std::atomic executed_callbacks_{0}; -}; - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 989c1535b01..a75d9dbcbc3 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -156,9 +156,9 @@ script: // Simulate a burst of defer operations like ratgdo does with state updates // These should execute immediately and recycle quickly to the pool + // Phase-specific id range (0..9) so ids never collide with later phases for (int i = 0; i < 10; i++) { - std::string defer_name = "defer_" + std::to_string(i); - App.scheduler.set_timeout(component, defer_name, 0, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 0, [i]() { ESP_LOGD("test", "Defer %d executed", i); // Force a small delay between defer executions to see recycling if (i == 5) { @@ -207,9 +207,9 @@ script: // Now create 8 new timeouts - they should reuse from pool when available int reuse_test_count = 8; + // Phase-specific id range (100..107) so ids never collide with other phases for (int i = 0; i < reuse_test_count; i++) { - std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(100 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -229,9 +229,9 @@ script: auto *component = id(test_sensor); int full_reuse_count = 10; + // Phase-specific id range (200..209) so ids never collide with other phases for (int i = 0; i < full_reuse_count; i++) { - std::string name = "full_reuse_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(200 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml deleted file mode 100644 index 5ae5a1914e7..00000000000 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ /dev/null @@ -1,48 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-string-lifetime-test - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_lifetime_component] - -host: - -logger: - level: DEBUG - -scheduler_string_lifetime_component: - id: string_lifetime - -api: - services: - - service: run_string_lifetime_test - then: - - lambda: |- - id(string_lifetime)->run_string_lifetime_test(); - - service: run_test1 - then: - - lambda: |- - id(string_lifetime)->run_test1(); - - service: run_test2 - then: - - lambda: |- - id(string_lifetime)->run_test2(); - - service: run_test3 - then: - - lambda: |- - id(string_lifetime)->run_test3(); - - service: run_test4 - then: - - lambda: |- - id(string_lifetime)->run_test4(); - - service: run_test5 - then: - - lambda: |- - id(string_lifetime)->run_test5(); - - service: run_final_check - then: - - lambda: |- - id(string_lifetime)->run_final_check(); diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml deleted file mode 100644 index 8f68d1d1023..00000000000 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ /dev/null @@ -1,39 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: sched-string-name-stress - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_name_stress_component] - -host: - -logger: - level: VERBOSE - -scheduler_string_name_stress_component: - id: string_stress - -api: - services: - - service: run_string_name_stress_test - then: - - lambda: |- - id(string_stress)->run_string_name_stress_test(); - -event: - - platform: template - name: "Test Complete" - id: test_complete - device_class: button - event_types: - - "test_finished" - - platform: template - name: "Test Result" - id: test_result - device_class: button - event_types: - - "passed" - - "failed" diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml index c53ec392df2..06e3a4c97c4 100644 --- a/tests/integration/fixtures/scheduler_string_test.yaml +++ b/tests/integration/fixtures/scheduler_string_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: dynamic_counter - type: int - initial_value: '0' - id: static_tests_done type: bool initial_value: 'false' @@ -103,46 +100,43 @@ script: - id: test_dynamic_strings then: - - logger.log: "Testing dynamic string timeouts and intervals" + - logger.log: "Testing const char* timeouts and intervals" - lambda: |- auto *component2 = id(test_sensor2); - // Test 8: Dynamic string with set_timeout (std::string) - std::string dynamic_name = "dynamic_timeout_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_timeout(component2, dynamic_name, 100, []() { + // Test 8: const char* name with set_timeout + App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() { ESP_LOGI("test", "Dynamic timeout fired"); id(timeout_counter) += 1; }); - // Test 9: Dynamic string with set_interval - std::string interval_name = "dynamic_interval_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_interval(component2, interval_name, 250, [interval_name]() { - ESP_LOGI("test", "Dynamic interval fired: %s", interval_name.c_str()); + // Test 9: const char* name with set_interval, cancelled from inside the callback + App.scheduler.set_interval(component2, "dynamic_interval", 250, []() { + ESP_LOGI("test", "Dynamic interval fired"); id(interval_counter) += 1; if (id(interval_counter) >= 6) { - App.scheduler.cancel_interval(id(test_sensor2), interval_name); + App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval"); ESP_LOGI("test", "Cancelled dynamic interval"); } }); - // Test 10: Cancel with different string object but same content - std::string cancel_name = "cancel_test"; - App.scheduler.set_timeout(component2, cancel_name, 2000, []() { + // Test 10: Cancel with a different pointer but identical content. + // STATIC_STRING names match by content, so a distinct static buffer with the + // same characters still cancels the scheduled timeout. + static const char CANCEL_NAME[] = "cancel_test"; + App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() { ESP_LOGI("test", "This should be cancelled"); }); + static const char CANCEL_NAME_2[] = "cancel_test"; + App.scheduler.cancel_timeout(component2, CANCEL_NAME_2); + ESP_LOGI("test", "Cancelled timeout using different buffer with same content"); - // Cancel using a different string object - std::string cancel_name_2 = "cancel_test"; - App.scheduler.cancel_timeout(component2, cancel_name_2); - ESP_LOGI("test", "Cancelled timeout using different string object"); - - // Test 11: Dynamic string with defer (using std::string overload) + // Test 11: const char* name with defer class TestDynamicDeferComponent : public Component { public: void test_dynamic_defer() { - std::string defer_name = "dynamic_defer_" + std::to_string(id(dynamic_counter)++); - this->defer(defer_name, [defer_name]() { - ESP_LOGI("test", "Dynamic defer fired: %s", defer_name.c_str()); + this->defer("dynamic_defer", []() { + ESP_LOGI("test", "Dynamic defer fired"); id(timeout_counter) += 1; }); } diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py deleted file mode 100644 index bfa581129b5..00000000000 --- a/tests/integration/test_scheduler_string_lifetime.py +++ /dev/null @@ -1,169 +0,0 @@ -"""String lifetime test - verify scheduler handles string destruction correctly.""" - -import asyncio -from pathlib import Path -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_lifetime( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler correctly handles string lifetimes when strings go out of scope.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create events for synchronization - test1_complete = asyncio.Event() - test2_complete = asyncio.Event() - test3_complete = asyncio.Event() - test4_complete = asyncio.Event() - test5_complete = asyncio.Event() - all_tests_complete = asyncio.Event() - - # Track test progress - test_stats = { - "tests_passed": 0, - "tests_failed": 0, - "errors": [], - "current_test": None, - "test_callbacks_executed": {}, - } - - def on_log_line(line: str) -> None: - # Track test-specific events - if "Test 1 complete" in line: - test1_complete.set() - elif "Test 2 complete" in line: - test2_complete.set() - elif "Test 3 complete" in line: - test3_complete.set() - elif "Test 4 complete" in line: - test4_complete.set() - elif "Test 5 complete" in line: - test5_complete.set() - - # Track individual callback executions - callback_match = re.search(r"Callback '(.+?)' executed", line) - if callback_match: - callback_name = callback_match.group(1) - test_stats["test_callbacks_executed"][callback_name] = True - - # Track test results from the C++ test output - if "Tests passed:" in line and "string_lifetime" in line: - # Extract the number from "Tests passed: 32" - match = re.search(r"Tests passed:\s*(\d+)", line) - if match: - test_stats["tests_passed"] = int(match.group(1)) - elif "Tests failed:" in line and "string_lifetime" in line: - match = re.search(r"Tests failed:\s*(\d+)", line) - if match: - test_stats["tests_failed"] = int(match.group(1)) - elif "ERROR" in line and "string_lifetime" in line: - test_stats["errors"].append(line) - - # Check for memory corruption indicators - if any( - indicator in line.lower() - for indicator in [ - "use after free", - "heap corruption", - "segfault", - "abort", - "assertion", - "sanitizer", - "bad memory", - "invalid pointer", - ] - ): - pytest.fail(f"Memory corruption detected: {line}") - - # Check for completion - if "String lifetime tests complete" in line: - all_tests_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-string-lifetime-test" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test services - test_services = {} - for service in services: - if service.name == "run_test1": - test_services["test1"] = service - elif service.name == "run_test2": - test_services["test2"] = service - elif service.name == "run_test3": - test_services["test3"] = service - elif service.name == "run_test4": - test_services["test4"] = service - elif service.name == "run_test5": - test_services["test5"] = service - elif service.name == "run_final_check": - test_services["final"] = service - - # Ensure all services are found - required_services = ["test1", "test2", "test3", "test4", "test5", "final"] - for service_name in required_services: - assert service_name in test_services, f"{service_name} service not found" - - # Run tests sequentially, waiting for each to complete - try: - # Test 1 - await client.execute_service(test_services["test1"], {}) - await asyncio.wait_for(test1_complete.wait(), timeout=5.0) - - # Test 2 - await client.execute_service(test_services["test2"], {}) - await asyncio.wait_for(test2_complete.wait(), timeout=5.0) - - # Test 3 - await client.execute_service(test_services["test3"], {}) - await asyncio.wait_for(test3_complete.wait(), timeout=5.0) - - # Test 4 - await client.execute_service(test_services["test4"], {}) - await asyncio.wait_for(test4_complete.wait(), timeout=5.0) - - # Test 5 - await client.execute_service(test_services["test5"], {}) - await asyncio.wait_for(test5_complete.wait(), timeout=5.0) - - # Final check - await client.execute_service(test_services["final"], {}) - await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) - - except TimeoutError: - pytest.fail(f"String lifetime test timed out. Stats: {test_stats}") - - # Check for any errors - assert test_stats["tests_failed"] == 0, f"Tests failed: {test_stats['errors']}" - - # Verify we had the expected number of passing tests - assert test_stats["tests_passed"] == 30, ( - f"Expected exactly 30 tests to pass, but got {test_stats['tests_passed']}" - ) diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py deleted file mode 100644 index 56b8998c565..00000000000 --- a/tests/integration/test_scheduler_string_name_stress.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Stress test for heap scheduler with std::string names from multiple threads.""" - -import asyncio -from pathlib import Path -import re - -from aioesphomeapi import UserService -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_name_stress( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that set_timeout/set_interval with std::string names doesn't crash when called from multiple threads.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create a future to signal test completion - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() - - # Track executed callbacks and any crashes - executed_callbacks: set[int] = set() - error_messages: list[str] = [] - - def on_log_line(line: str) -> None: - # Check for crash indicators - if any( - indicator in line.lower() - for indicator in [ - "segfault", - "abort", - "assertion", - "heap corruption", - "use after free", - ] - ): - error_messages.append(line) - if not test_complete_future.done(): - test_complete_future.set_exception(Exception(f"Crash detected: {line}")) - return - - # Track executed callbacks - match = re.search(r"Executed string-named callback (\d+)", line) - if match: - callback_id = int(match.group(1)) - executed_callbacks.add(callback_id) - - # Check for completion - if ( - "String name stress test complete" in line - and not test_complete_future.done() - ): - test_complete_future.set_result(None) - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "sched-string-name-stress" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test service - run_stress_test_service: UserService | None = None - for service in services: - if service.name == "run_string_name_stress_test": - run_stress_test_service = service - break - - assert run_stress_test_service is not None, ( - "run_string_name_stress_test service not found" - ) - - # Call the service to start the test - await client.execute_service(run_stress_test_service, {}) - - # Wait for test to complete or crash - try: - await asyncio.wait_for(test_complete_future, timeout=30.0) - except TimeoutError: - pytest.fail( - f"String name stress test timed out. Executed {len(executed_callbacks)} callbacks. " - f"This might indicate a deadlock." - ) - - # Verify no errors occurred (crashes already handled by exception) - assert not error_messages, f"Errors detected during test: {error_messages}" - - # Verify we executed all 1000 callbacks (10 threads × 100 callbacks each) - assert len(executed_callbacks) == 1000, ( - f"Expected 1000 callbacks but got {len(executed_callbacks)}" - ) - - # Verify each callback ID was executed exactly once - for i in range(1000): - assert i in executed_callbacks, f"Callback {i} was not executed" diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py index 783ed37c133..3bc3487432d 100644 --- a/tests/integration/test_scheduler_string_test.py +++ b/tests/integration/test_scheduler_string_test.py @@ -99,7 +99,7 @@ async def test_scheduler_string_test( timeout_count += 1 # Check for cancel test - elif "Cancelled timeout using different string object" in clean_line: + elif "Cancelled timeout using different buffer with same content" in clean_line: cancel_test_done.set() # Check for final results From d0e3e98d552d03e7cc2f0896b48c26b6f32dc4bd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:33:27 +1200 Subject: [PATCH 095/343] [dashboard] Remove legacy web dashboard (#17124) --- .github/scripts/detect-tags.js | 1 - .../dashboard-deprecation-comment.yml | 119 -- AGENTS.md | 4 +- esphome/__main__.py | 68 +- .../components/dashboard_import/__init__.py | 1 - esphome/components/esp32/__init__.py | 16 +- esphome/components/esp8266/__init__.py | 16 +- esphome/components/libretiny/__init__.py | 16 +- esphome/components/rp2040/__init__.py | 16 +- esphome/dashboard/__init__.py | 0 esphome/dashboard/const.py | 32 - esphome/dashboard/core.py | 190 -- esphome/dashboard/dashboard.py | 153 -- esphome/dashboard/dns.py | 77 - esphome/dashboard/entries.py | 458 ---- esphome/dashboard/models.py | 76 - esphome/dashboard/settings.py | 101 - esphome/dashboard/status/__init__.py | 0 esphome/dashboard/status/mdns.py | 170 -- esphome/dashboard/status/mqtt.py | 78 - esphome/dashboard/status/ping.py | 151 -- esphome/dashboard/util/__init__.py | 0 esphome/dashboard/util/itertools.py | 22 - esphome/dashboard/util/password.py | 11 - esphome/dashboard/util/subprocess.py | 31 - esphome/dashboard/util/text.py | 15 - esphome/dashboard/web_server.py | 1645 -------------- esphome/helpers.py | 10 +- esphome/storage_json.py | 12 +- esphome/zeroconf.py | 24 +- requirements.txt | 3 - script/ci-custom.py | 9 +- tests/dashboard/__init__.py | 0 tests/dashboard/common.py | 6 - tests/dashboard/conftest.py | 43 - tests/dashboard/fixtures/conf/pico.yaml | 47 - tests/dashboard/status/__init__.py | 0 tests/dashboard/status/test_dns.py | 199 -- tests/dashboard/status/test_mdns.py | 240 --- tests/dashboard/test_entries.py | 288 --- tests/dashboard/test_settings.py | 287 --- tests/dashboard/test_web_server.py | 1889 ----------------- tests/dashboard/test_web_server_paths.py | 219 -- tests/dashboard/util/__init__.py | 0 tests/script/test_determine_jobs.py | 3 +- tests/unit_tests/test_helpers.py | 16 - tests/unit_tests/test_main.py | 40 + 47 files changed, 109 insertions(+), 6693 deletions(-) delete mode 100644 .github/workflows/dashboard-deprecation-comment.yml delete mode 100644 esphome/dashboard/__init__.py delete mode 100644 esphome/dashboard/const.py delete mode 100644 esphome/dashboard/core.py delete mode 100644 esphome/dashboard/dashboard.py delete mode 100644 esphome/dashboard/dns.py delete mode 100644 esphome/dashboard/entries.py delete mode 100644 esphome/dashboard/models.py delete mode 100644 esphome/dashboard/settings.py delete mode 100644 esphome/dashboard/status/__init__.py delete mode 100644 esphome/dashboard/status/mdns.py delete mode 100644 esphome/dashboard/status/mqtt.py delete mode 100644 esphome/dashboard/status/ping.py delete mode 100644 esphome/dashboard/util/__init__.py delete mode 100644 esphome/dashboard/util/itertools.py delete mode 100644 esphome/dashboard/util/password.py delete mode 100644 esphome/dashboard/util/subprocess.py delete mode 100644 esphome/dashboard/util/text.py delete mode 100644 esphome/dashboard/web_server.py delete mode 100644 tests/dashboard/__init__.py delete mode 100644 tests/dashboard/common.py delete mode 100644 tests/dashboard/conftest.py delete mode 100644 tests/dashboard/fixtures/conf/pico.yaml delete mode 100644 tests/dashboard/status/__init__.py delete mode 100644 tests/dashboard/status/test_dns.py delete mode 100644 tests/dashboard/status/test_mdns.py delete mode 100644 tests/dashboard/test_entries.py delete mode 100644 tests/dashboard/test_settings.py delete mode 100644 tests/dashboard/test_web_server.py delete mode 100644 tests/dashboard/test_web_server_paths.py delete mode 100644 tests/dashboard/util/__init__.py diff --git a/.github/scripts/detect-tags.js b/.github/scripts/detect-tags.js index 3933776c616..99caccc2f87 100644 --- a/.github/scripts/detect-tags.js +++ b/.github/scripts/detect-tags.js @@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) { */ function hasDashboardChanges(changedFiles) { return changedFiles.some(file => - file.startsWith('esphome/dashboard/') || file.startsWith('esphome/components/dashboard_import/') ); } diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml deleted file mode 100644 index ffd5ec7bd92..00000000000 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Add Dashboard Deprecation Comment - -on: - pull_request_target: - types: [opened, synchronize] - -# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with -# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. -permissions: {} - -jobs: - dashboard-deprecation-comment: - name: Dashboard deprecation comment - runs-on: ubuntu-latest - # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably - # roll up everything merged into dev since the last cut, which can - # include dashboard changes that have already been reviewed once. - # The bot's purpose is to warn new contributors before they invest - # time -- that only applies to PRs entering dev. - if: github.event.pull_request.base.ref == 'dev' - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources - # the issues.*Comment APIs require the pull-requests scope, not issues. - permission-pull-requests: write - - - name: Add dashboard deprecation comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const commentMarker = ""; - - const commentBody = `Thanks for opening this PR! - - Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository. - - What this means for your PR: - - - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here. - - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life. - - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix. - - We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land. - - --- - (Added by the PR bot) - - ${commentMarker}`; - - async function getDashboardChanges(github, owner, repo, prNumber) { - const changedFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner: owner, - repo: repo, - pull_number: prNumber, - per_page: 100, - } - ); - - return changedFiles.filter(file => - file.filename.startsWith('esphome/dashboard/') || - file.filename.startsWith('tests/dashboard/') - ); - } - - async function findBotComment(github, owner, repo, prNumber) { - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner: owner, - repo: repo, - issue_number: prNumber, - per_page: 100, - } - ); - - return comments.find(comment => - comment.body.includes(commentMarker) && comment.user.type === "Bot" - ); - } - - const prNumber = context.payload.pull_request.number; - const { owner, repo } = context.repo; - - const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber); - const existingComment = await findBotComment(github, owner, repo, prNumber); - - if (dashboardChanges.length === 0) { - // PR doesn't (or no longer) touches the legacy dashboard. If we previously - // commented (e.g. files were removed in a later push), leave the comment in - // place for history rather than thrash on edit/delete. - return; - } - - if (existingComment) { - if (existingComment.body === commentBody) { - return; - } - await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: existingComment.id, - body: commentBody, - }); - } else { - await github.rest.issues.createComment({ - owner: owner, - repo: repo, - issue_number: prNumber, - body: commentBody, - }); - } diff --git a/AGENTS.md b/AGENTS.md index 4346ffbdae0..be2e912d486 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,6 @@ This document provides essential context for AI models interacting with this pro 2. **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management. 3. **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management. 4. **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration. - 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. * **Platform Support:** 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3). @@ -456,7 +455,6 @@ This document provides essential context for AI models interacting with this pro * **Debug Tools:** - `esphome config .yaml` to validate configuration. - `esphome compile .yaml` to compile without uploading. - - Check the Dashboard for real-time logs. - Use component-specific debug logging. * **Common Issues:** - **Import Errors**: Check component dependencies and `PYTHONPATH`. @@ -658,7 +656,7 @@ This document provides essential context for AI models interacting with this pro If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs. **Why this matters:** - - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec + - Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec - `CORE.data` automatically clears between runs - Namespacing under `DOMAIN` prevents key collisions between components - `@dataclass` provides type safety and cleaner attribute access diff --git a/esphome/__main__.py b/esphome/__main__.py index 680de02201f..35ab767cf74 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -527,7 +527,7 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True - # The dashboard pre-resolves the device and passes the IPs via + # device-builder pre-resolves the device and passes the IPs via # --mdns-address-cache/--dns-address-cache; honor a cached address even when the # device has mDNS disabled (e.g. a .local host found via ping). if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): @@ -1715,9 +1715,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: def command_dashboard(args: ArgsProtocol) -> int | None: - from esphome.dashboard import dashboard - - return dashboard.start_dashboard(args) + raise EsphomeError( + "The built-in dashboard has been removed from ESPHome. " + "Install and run ESPHome Device Builder instead:\n" + " pip install esphome-device-builder\n" + " esphome-device-builder\n" + "See https://github.com/esphome/device-builder for more information." + ) def run_multiple_configs( @@ -2379,44 +2383,22 @@ def parse_args(argv): "configuration", help="Your YAML file or configuration directory.", nargs="*" ) - parser_dashboard = subparsers.add_parser( - "dashboard", help="Create a simple web server for a dashboard." + # The dashboard moved to ESPHome Device Builder; the command is kept only to + # print a redirect (see command_dashboard). Accept and ignore the old flags + # so legacy invocations reach that message instead of failing on argparse + # "unrecognized arguments". + parser_dashboard = subparsers.add_parser("dashboard") + parser_dashboard.add_argument("configuration", nargs="?", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--port", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--address", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--username", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--password", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--socket", help=argparse.SUPPRESS) + parser_dashboard.add_argument( + "--open-ui", action="store_true", help=argparse.SUPPRESS ) parser_dashboard.add_argument( - "configuration", help="Your YAML configuration file directory." - ) - parser_dashboard.add_argument( - "--port", - help="The HTTP port to open connections on. Defaults to 6052.", - type=int, - default=6052, - ) - parser_dashboard.add_argument( - "--address", - help="The address to bind to.", - type=str, - default="0.0.0.0", - ) - parser_dashboard.add_argument( - "--username", - help="The optional username to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--password", - help="The optional password to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--open-ui", help="Open the dashboard UI in a browser.", action="store_true" - ) - parser_dashboard.add_argument( - "--ha-addon", help=argparse.SUPPRESS, action="store_true" - ) - parser_dashboard.add_argument( - "--socket", help="Make the dashboard serve under a unix socket", type=str + "--ha-addon", action="store_true", help=argparse.SUPPRESS ) parser_vscode = subparsers.add_parser("vscode") @@ -2511,11 +2493,7 @@ def run_esphome(argv): elif args.quiet: args.log_level = "CRITICAL" - setup_log( - log_level=args.log_level, - # Show timestamp for dashboard access logs - include_timestamp=args.command == "dashboard", - ) + setup_log(log_level=args.log_level) if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 30b33941653..911fc387a0a 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -92,7 +92,6 @@ def import_config( """Materialise a dashboard-imported device's YAML on disk. Used by: - - esphome.dashboard (legacy dashboard) - device-builder (esphome/device-builder) — called from the ``devices/import`` WS handler to seed the YAML for an adopted factory firmware. Coordinate before changing the kwargs or the diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ec33d9d271e..8ba1ac4608d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -533,15 +533,13 @@ def get_board(core_obj=None): def get_download_types(storage_json): """Binary-download entries for a built ESP32 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db94f0ec6d2..db7120a9ef6 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -97,15 +97,13 @@ def set_core_data(config): def get_download_types(storage_json): """Binary-download entries for a built ESP8266 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index afe0360c22f..bcc393f3fd0 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -158,15 +158,13 @@ def only_on_family(*, supported=None, unsupported=None): def get_download_types(storage_json: StorageJSON = None): """Binary-download entries for a built LibreTiny firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ types = [ { diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index f98cde7968d..dd851b8e168 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -140,15 +140,13 @@ def only_on_variant( def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { diff --git a/esphome/dashboard/__init__.py b/esphome/dashboard/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py deleted file mode 100644 index 9cadc442ef2..00000000000 --- a/esphome/dashboard/const.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import sys - -from esphome.enum import StrEnum - - -class DashboardEvent(StrEnum): - """Dashboard WebSocket event types.""" - - # Server -> Client events (backend sends to frontend) - ENTRY_ADDED = "entry_added" - ENTRY_REMOVED = "entry_removed" - ENTRY_UPDATED = "entry_updated" - ENTRY_STATE_CHANGED = "entry_state_changed" - IMPORTABLE_DEVICE_ADDED = "importable_device_added" - IMPORTABLE_DEVICE_REMOVED = "importable_device_removed" - INITIAL_STATE = "initial_state" # Sent on WebSocket connection - PONG = "pong" # Response to client ping - - # Client -> Server events (frontend sends to backend) - PING = "ping" # WebSocket keepalive from client - REFRESH = "refresh" # Force backend to poll for changes - - -MAX_EXECUTOR_WORKERS = 48 - - -SENTINEL = object() - -ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] -DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py deleted file mode 100644 index b9ec56cd00a..00000000000 --- a/esphome/dashboard/core.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -import contextlib -from dataclasses import dataclass -from functools import partial -import json -import logging -import threading -from typing import Any - -from esphome.storage_json import ignored_devices_storage_path - -from ..zeroconf import DiscoveredImport -from .const import DashboardEvent -from .dns import DNSCache -from .entries import DashboardEntries -from .settings import DashboardSettings -from .status.mdns import MDNSStatus -from .status.ping import PingStatus - -_LOGGER = logging.getLogger(__name__) - -IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json" - -MDNS_BOOTSTRAP_TIME = 7.5 - - -@dataclass -class Event: - """Dashboard Event.""" - - event_type: DashboardEvent - data: dict[str, Any] - - -class EventBus: - """Dashboard event bus.""" - - def __init__(self) -> None: - """Initialize the Dashboard event bus.""" - self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {} - - def async_add_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> Callable[[], None]: - """Add a listener to the event bus.""" - self._listeners.setdefault(event_type, set()).add(listener) - return partial(self._async_remove_listener, event_type, listener) - - def _async_remove_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> None: - """Remove a listener from the event bus.""" - self._listeners[event_type].discard(listener) - - def async_fire( - self, event_type: DashboardEvent, event_data: dict[str, Any] - ) -> None: - """Fire an event.""" - event = Event(event_type, event_data) - - _LOGGER.debug("Firing event: %s", event) - - for listener in self._listeners.get(event_type, set()): - listener(event) - - -class ESPHomeDashboard: - """Class that represents the dashboard.""" - - __slots__ = ( - "bus", - "entries", - "loop", - "import_result", - "stop_event", - "ping_request", - "mqtt_ping_request", - "mdns_status", - "settings", - "dns_cache", - "_background_tasks", - "ignored_devices", - "_ping_status_task", - ) - - def __init__(self) -> None: - """Initialize the ESPHomeDashboard.""" - self.bus = EventBus() - self.entries: DashboardEntries | None = None - self.loop: asyncio.AbstractEventLoop | None = None - self.import_result: dict[str, DiscoveredImport] = {} - self.stop_event = threading.Event() - self.ping_request: asyncio.Event | None = None - self.mqtt_ping_request = threading.Event() - self.mdns_status: MDNSStatus | None = None - self.settings = DashboardSettings() - self.dns_cache = DNSCache() - self._background_tasks: set[asyncio.Task] = set() - self.ignored_devices: set[str] = set() - self._ping_status_task: asyncio.Task | None = None - - async def async_setup(self) -> None: - """Setup the dashboard.""" - self.loop = asyncio.get_running_loop() - self.ping_request = asyncio.Event() - self.entries = DashboardEntries(self) - await self.loop.run_in_executor(None, self.load_ignored_devices) - - def load_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - try: - with storage_path.open("r", encoding="utf-8") as f_handle: - data = json.load(f_handle) - self.ignored_devices = set(data.get("ignored_devices", set())) - except FileNotFoundError: - pass - - def save_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - with storage_path.open("w", encoding="utf-8") as f_handle: - json.dump( - {"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle - ) - - def _async_start_ping_status(self, ping_status: PingStatus) -> None: - self._ping_status_task = asyncio.create_task(ping_status.async_run()) - - async def async_run(self) -> None: - """Run the dashboard.""" - settings = self.settings - mdns_task: asyncio.Task | None = None - await self.entries.async_update_entries() - - mdns_status = MDNSStatus(self) - ping_status = PingStatus(self) - start_ping_timer: asyncio.TimerHandle | None = None - - self.mdns_status = mdns_status - if mdns_status.async_setup(): - mdns_task = asyncio.create_task(mdns_status.async_run()) - # Start ping MDNS_BOOTSTRAP_TIME seconds after startup to ensure - # MDNS has had a chance to resolve the devices - start_ping_timer = self.loop.call_later( - MDNS_BOOTSTRAP_TIME, self._async_start_ping_status, ping_status - ) - else: - # If mDNS is not available, start the ping status immediately - self._async_start_ping_status(ping_status) - - if settings.status_use_mqtt: - from .status.mqtt import MqttStatusThread - - status_thread_mqtt = MqttStatusThread(self) - status_thread_mqtt.start() - - try: - await asyncio.Event().wait() - finally: - _LOGGER.info("Shutting down...") - self.stop_event.set() - self.ping_request.set() - if start_ping_timer: - start_ping_timer.cancel() - if self._ping_status_task: - self._ping_status_task.cancel() - self._ping_status_task = None - if mdns_task: - mdns_task.cancel() - if settings.status_use_mqtt: - status_thread_mqtt.join() - self.mqtt_ping_request.set() - for task in self._background_tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await asyncio.sleep(0) - - def async_create_background_task( - self, coro: Coroutine[Any, Any, Any] - ) -> asyncio.Task: - """Create a background task.""" - task = self.loop.create_task(coro) - task.add_done_callback(self._background_tasks.discard) - return task - - -DASHBOARD = ESPHomeDashboard() diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py deleted file mode 100644 index 7fc21f8a440..00000000000 --- a/esphome/dashboard/dashboard.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -import asyncio -from asyncio import events -from concurrent.futures import ThreadPoolExecutor -import contextlib -import logging -import os -from pathlib import Path -import socket -import threading -from time import monotonic -import traceback -from typing import Any - -from esphome.storage_json import EsphomeStorageJSON, esphome_storage_path - -from .const import MAX_EXECUTOR_WORKERS -from .core import DASHBOARD -from .web_server import make_app, start_web_server - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -settings = DASHBOARD.settings - - -def can_use_pidfd() -> bool: - """Check if pidfd_open is available. - - Back ported from cpython 3.12 - """ - if not hasattr(os, "pidfd_open"): - return False - try: - pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) - except OSError: - # blocked by security policy like SECCOMP - return False - return True - - -class DashboardEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - """Event loop policy for Home Assistant.""" - - def __init__(self, debug: bool) -> None: - """Init the event loop policy.""" - super().__init__() - self.debug = debug - self._watcher: asyncio.AbstractChildWatcher | None = None - - def _init_watcher(self) -> None: - """Initialize the watcher for child processes. - - Back ported from cpython 3.12 - """ - with events._lock: # type: ignore[attr-defined] # pylint: disable=protected-access - if self._watcher is None: # pragma: no branch - if can_use_pidfd(): - self._watcher = asyncio.PidfdChildWatcher() - else: - self._watcher = asyncio.ThreadedChildWatcher() - if threading.current_thread() is threading.main_thread(): - self._watcher.attach_loop( - self._local._loop # type: ignore[attr-defined] # pylint: disable=protected-access - ) - - @property - def loop_name(self) -> str: - """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return,attr-defined] - - def new_event_loop(self) -> asyncio.AbstractEventLoop: - """Get the event loop.""" - loop: asyncio.AbstractEventLoop = super().new_event_loop() - loop.set_exception_handler(_async_loop_exception_handler) - - if self.debug: - loop.set_debug(True) - - executor = ThreadPoolExecutor( - thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS - ) - loop.set_default_executor(executor) - # bind the built-in time.monotonic directly as loop.time to avoid the - # overhead of the additional method call since its the most called loop - # method and its roughly 10%+ of all the call time in base_events.py - loop.time = monotonic # type: ignore[method-assign] - return loop - - -def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: - """Handle all exception inside the core loop.""" - kwargs = {} - if exception := context.get("exception"): - kwargs["exc_info"] = (type(exception), exception, exception.__traceback__) - - logger = logging.getLogger(__package__) - if source_traceback := context.get("source_traceback"): - stack_summary = "".join(traceback.format_list(source_traceback)) - logger.error( - "Error doing job: %s: %s", - context["message"], - stack_summary, - **kwargs, # type: ignore[arg-type] - ) - return - - logger.error( - "Error doing job: %s", - context["message"], - **kwargs, # type: ignore[arg-type] - ) - - -def start_dashboard(args) -> None: - """Start the dashboard.""" - settings.parse_args(args) - - if settings.using_auth: - path = esphome_storage_path() - storage = EsphomeStorageJSON.load(path) - if storage is None: - storage = EsphomeStorageJSON.get_default() - storage.save(path) - settings.cookie_secret = storage.cookie_secret - - asyncio.set_event_loop_policy(DashboardEventLoopPolicy(settings.verbose)) - - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_start(args)) - - -async def async_start(args) -> None: - """Start the dashboard.""" - dashboard = DASHBOARD - await dashboard.async_setup() - sock: socket.socket | None = args.socket - address: str | None = args.address - port: int | None = args.port - - start_web_server(make_app(args.verbose), sock, address, port, settings.config_dir) - - if args.open_ui: - import webbrowser - - webbrowser.open(f"http://{args.address}:{args.port}") - - try: - await dashboard.async_run() - finally: - if sock: - Path(sock).unlink() diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py deleted file mode 100644 index eb4a87dbfbd..00000000000 --- a/esphome/dashboard/dns.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import suppress -from ipaddress import ip_address -import logging - -from icmplib import NameLookupError, async_resolve - -RESOLVE_TIMEOUT = 3.0 - -_LOGGER = logging.getLogger(__name__) - -_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError) - - -async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: - """Wrap the icmplib async_resolve function.""" - with suppress(ValueError): - return [str(ip_address(hostname))] - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - return await async_resolve(hostname) - except _RESOLVE_EXCEPTIONS as ex: - # If the hostname ends with .local and resolution failed, - # try the bare hostname as a fallback since mDNS may not be - # working on the system but unicast DNS might resolve it - if hostname.endswith(".local"): - bare_hostname = hostname[:-6] # Remove ".local" - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - result = await async_resolve(bare_hostname) - _LOGGER.debug( - "Bare hostname %s resolved to %s", bare_hostname, result - ) - return result - except _RESOLVE_EXCEPTIONS: - _LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname) - return ex - - -class DNSCache: - """DNS cache for the dashboard.""" - - def __init__(self, ttl: int | None = 120) -> None: - """Initialize the DNSCache.""" - self._cache: dict[str, tuple[float, list[str] | Exception]] = {} - self._ttl = ttl - - def get_cached_addresses( - self, hostname: str, now_monotonic: float - ) -> list[str] | None: - """Get cached addresses without triggering resolution. - - Returns None if not in cache, list of addresses if found. - """ - # Normalize hostname for consistent lookups - normalized = hostname.rstrip(".").lower() - if expire_time_addresses := self._cache.get(normalized): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic and not isinstance(addresses, Exception): - return addresses - return None - - async def async_resolve( - self, hostname: str, now_monotonic: float - ) -> list[str] | Exception: - """Resolve a hostname to a list of IP address.""" - if expire_time_addresses := self._cache.get(hostname): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic: - return addresses - - expires = now_monotonic + self._ttl - addresses = await _async_resolve_wrapper(hostname) - self._cache[hostname] = (expires, addresses) - return addresses diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py deleted file mode 100644 index 95b8a7b2ae4..00000000000 --- a/esphome/dashboard/entries.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import defaultdict -from dataclasses import dataclass -from functools import lru_cache -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from esphome import const, util -from esphome.enum import StrEnum -from esphome.storage_json import StorageJSON, ext_storage_path - -from .const import DASHBOARD_COMMAND, DashboardEvent -from .util.subprocess import async_run_system_command - -if TYPE_CHECKING: - from .core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -DashboardCacheKeyType = tuple[int, int, float, int] - - -@dataclass(frozen=True) -class EntryState: - """Represents the state of an entry.""" - - reachable: ReachableState - source: EntryStateSource - - -class EntryStateSource(StrEnum): - MDNS = "mdns" - PING = "ping" - MQTT = "mqtt" - UNKNOWN = "unknown" - - -class ReachableState(StrEnum): - ONLINE = "online" - OFFLINE = "offline" - DNS_FAILURE = "dns_failure" - UNKNOWN = "unknown" - - -_BOOL_TO_REACHABLE_STATE = { - True: ReachableState.ONLINE, - False: ReachableState.OFFLINE, - None: ReachableState.UNKNOWN, -} -_REACHABLE_STATE_TO_BOOL = { - ReachableState.ONLINE: True, - ReachableState.OFFLINE: False, - ReachableState.DNS_FAILURE: False, - ReachableState.UNKNOWN: None, -} - -UNKNOWN_STATE = EntryState(ReachableState.UNKNOWN, EntryStateSource.UNKNOWN) - - -@lru_cache # creating frozen dataclass instances is expensive, so we cache them -def bool_to_entry_state(value: bool | None, source: EntryStateSource) -> EntryState: - """Convert a bool to an entry state.""" - return EntryState(_BOOL_TO_REACHABLE_STATE[value], source) - - -def entry_state_to_bool(value: EntryState) -> bool | None: - """Convert an entry state to a bool.""" - return _REACHABLE_STATE_TO_BOOL[value.reachable] - - -class DashboardEntries: - """Represents all dashboard entries.""" - - __slots__ = ( - "_dashboard", - "_loop", - "_config_dir", - "_entries", - "_entry_states", - "_loaded_entries", - "_update_lock", - "_name_to_entry", - ) - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the DashboardEntries.""" - self._dashboard = dashboard - self._loop = asyncio.get_running_loop() - self._config_dir = dashboard.settings.config_dir - # Entries are stored as - # { - # "path/to/file.yaml": DashboardEntry, - # ... - # } - self._entries: dict[Path, DashboardEntry] = {} - self._loaded_entries = False - self._update_lock = asyncio.Lock() - self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set) - - def get(self, path: Path) -> DashboardEntry | None: - """Get an entry by path.""" - return self._entries.get(path) - - def get_by_name(self, name: str) -> set[DashboardEntry] | None: - """Get an entry by name.""" - return self._name_to_entry.get(name) - - async def _async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def all(self) -> list[DashboardEntry]: - """Return all entries.""" - return asyncio.run_coroutine_threadsafe(self._async_all(), self._loop).result() - - def async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state(entry, state), self._loop - ).result() - - async def _async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - self.async_set_state(entry, state) - - def set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_online_or_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - self.async_set_state_if_online_or_source(entry, state) - - def async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - if ( - state.reachable is ReachableState.ONLINE - and entry.state.reachable is not ReachableState.ONLINE - ) or entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def set_state_if_source(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry if provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if rovided by the source or unknown.""" - self.async_set_state_if_source(entry, state) - - def async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if provided by the source or unknown.""" - if entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - if entry.state == state: - return - entry.state = state - self._dashboard.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - async def async_request_update_entries(self) -> None: - """Request an update of the dashboard entries from disk. - - If an update is already in progress, this will do nothing. - """ - if self._update_lock.locked(): - _LOGGER.debug("Dashboard entries are already being updated") - return - await self.async_update_entries() - - async def async_update_entries(self) -> None: - """Update the dashboard entries from disk.""" - async with self._update_lock: - await self._async_update_entries() - - def _load_entries( - self, entries: dict[DashboardEntry, DashboardCacheKeyType] - ) -> None: - """Load all entries from disk.""" - for entry, cache_key in entries.items(): - _LOGGER.debug( - "Loading dashboard entry %s because cache key changed: %s", - entry.path, - cache_key, - ) - entry.load_from_disk(cache_key) - - async def _async_update_entries(self) -> list[DashboardEntry]: - """Sync the dashboard entries from disk.""" - _LOGGER.debug("Updating dashboard entries") - # At some point it would be nice to use watchdog to avoid polling - - path_to_cache_key = await self._loop.run_in_executor( - None, self._get_path_to_cache_key - ) - entries = self._entries - name_to_entry = self._name_to_entry - added: dict[DashboardEntry, DashboardCacheKeyType] = {} - updated: dict[DashboardEntry, DashboardCacheKeyType] = {} - removed: set[DashboardEntry] = { - entry - for filename, entry in entries.items() - if filename not in path_to_cache_key - } - original_names: dict[DashboardEntry, str] = {} - - for path, cache_key in path_to_cache_key.items(): - if not (entry := entries.get(path)): - entry = DashboardEntry(path, cache_key) - added[entry] = cache_key - continue - - if entry.cache_key != cache_key: - updated[entry] = cache_key - original_names[entry] = entry.name - - if added or updated: - await self._loop.run_in_executor( - None, self._load_entries, {**added, **updated} - ) - - bus = self._dashboard.bus - for entry in added: - entries[entry.path] = entry - name_to_entry[entry.name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": entry}) - - for entry in removed: - del entries[entry.path] - name_to_entry[entry.name].discard(entry) - bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": entry}) - - for entry in updated: - if (original_name := original_names[entry]) != (current_name := entry.name): - name_to_entry[original_name].discard(entry) - name_to_entry[current_name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry}) - - def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]: - """Return a dict of path to cache key.""" - path_to_cache_key: dict[Path, DashboardCacheKeyType] = {} - # - # The cache key is (inode, device, mtime, size) - # which allows us to avoid locking since it ensures - # every iteration of this call will always return the newest - # items from disk at the cost of a stat() call on each - # file which is much faster than reading the file - # for the cache hit case which is the common case. - # - for file in util.list_yaml_files([self._config_dir]): - try: - # Prefer the json storage path if it exists - stat = ext_storage_path(file.name).stat() - except OSError: - try: - # Fallback to the yaml file if the storage - # file does not exist or could not be generated - stat = file.stat() - except OSError: - # File was deleted, ignore - continue - path_to_cache_key[file] = ( - stat.st_ino, - stat.st_dev, - stat.st_mtime, - stat.st_size, - ) - return path_to_cache_key - - def async_schedule_storage_json_update(self, filename: str) -> None: - """Schedule a task to update the storage JSON file.""" - self._dashboard.async_create_background_task( - async_run_system_command( - [*DASHBOARD_COMMAND, "compile", "--only-generate", filename] - ) - ) - - -class DashboardEntry: - """Represents a single dashboard entry. - - This class is thread-safe and read-only. - """ - - __slots__ = ( - "path", - "filename", - "_storage_path", - "cache_key", - "storage", - "state", - "_to_dict", - ) - - def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None: - """Initialize the DashboardEntry.""" - self.path = path - self.filename: str = path.name - self._storage_path = ext_storage_path(self.filename) - self.cache_key = cache_key - self.storage: StorageJSON | None = None - self.state = UNKNOWN_STATE - self._to_dict: dict[str, Any] | None = None - - def __repr__(self) -> str: - """Return the representation of this entry.""" - return ( - f"DashboardEntry(path={self.path} " - f"address={self.address} " - f"web_port={self.web_port} " - f"name={self.name} " - f"no_mdns={self.no_mdns} " - f"state={self.state} " - ")" - ) - - def to_dict(self) -> dict[str, Any]: - """Return a dict representation of this entry. - - The dict includes the loaded configuration but not - the current state of the entry. - """ - if self._to_dict is None: - self._to_dict = { - "name": self.name, - "friendly_name": self.friendly_name, - "configuration": self.filename, - "loaded_integrations": sorted(self.loaded_integrations), - "deployed_version": self.update_old, - "current_version": self.update_new, - "path": str(self.path), - "comment": self.comment, - "address": self.address, - "web_port": self.web_port, - "target_platform": self.target_platform, - } - return self._to_dict - - def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None: - """Load this entry from disk.""" - self.storage = StorageJSON.load(self._storage_path) - self._to_dict = None - # - # Currently StorageJSON.load() will return None if the file does not exist - # - # StorageJSON currently does not provide an updated cache key so we use the - # one that is passed in. - # - # The cache key was read from the disk moments ago and may be stale but - # it does not matter since we are polling anyways, and the next call to - # async_update_entries() will load it again in the extremely rare case that - # it changed between the two calls. - # - if cache_key: - self.cache_key = cache_key - - @property - def address(self) -> str | None: - """Return the address of this entry.""" - if self.storage is None: - return None - return self.storage.address - - @property - def no_mdns(self) -> bool | None: - """Return the no_mdns of this entry.""" - if self.storage is None: - return None - return self.storage.no_mdns - - @property - def web_port(self) -> int | None: - """Return the web port of this entry.""" - if self.storage is None: - return None - return self.storage.web_port - - @property - def name(self) -> str: - """Return the name of this entry.""" - if self.storage is None: - return self.filename.replace(".yml", "").replace(".yaml", "") - return self.storage.name - - @property - def friendly_name(self) -> str: - """Return the friendly name of this entry.""" - if self.storage is None: - return self.name - return self.storage.friendly_name - - @property - def comment(self) -> str | None: - """Return the comment of this entry.""" - if self.storage is None: - return None - return self.storage.comment - - @property - def target_platform(self) -> str | None: - """Return the target platform of this entry.""" - if self.storage is None: - return None - return self.storage.target_platform - - @property - def update_available(self) -> bool: - """Return if an update is available for this entry.""" - if self.storage is None: - return True - return self.update_old != self.update_new - - @property - def update_old(self) -> str: - if self.storage is None: - return "" - return self.storage.esphome_version or "" - - @property - def update_new(self) -> str: - return const.__version__ - - @property - def loaded_integrations(self) -> set[str]: - if self.storage is None: - return [] - return self.storage.loaded_integrations diff --git a/esphome/dashboard/models.py b/esphome/dashboard/models.py deleted file mode 100644 index 47ddddd5ce6..00000000000 --- a/esphome/dashboard/models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Data models and builders for the dashboard.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, TypedDict - -if TYPE_CHECKING: - from esphome.zeroconf import DiscoveredImport - - from .core import ESPHomeDashboard - from .entries import DashboardEntry - - -class ImportableDeviceDict(TypedDict): - """Dictionary representation of an importable device.""" - - name: str - friendly_name: str | None - package_import_url: str - project_name: str - project_version: str - network: str - ignored: bool - - -class ConfiguredDeviceDict(TypedDict, total=False): - """Dictionary representation of a configured device.""" - - name: str - friendly_name: str | None - configuration: str - loaded_integrations: list[str] | None - deployed_version: str | None - current_version: str | None - path: str - comment: str | None - address: str | None - web_port: int | None - target_platform: str | None - - -class DeviceListResponse(TypedDict): - """Response for device list API.""" - - configured: list[ConfiguredDeviceDict] - importable: list[ImportableDeviceDict] - - -def build_importable_device_dict( - dashboard: ESPHomeDashboard, discovered: DiscoveredImport -) -> ImportableDeviceDict: - """Build the importable device dictionary.""" - return ImportableDeviceDict( - name=discovered.device_name, - friendly_name=discovered.friendly_name, - package_import_url=discovered.package_import_url, - project_name=discovered.project_name, - project_version=discovered.project_version, - network=discovered.network, - ignored=discovered.device_name in dashboard.ignored_devices, - ) - - -def build_device_list_response( - dashboard: ESPHomeDashboard, entries: list[DashboardEntry] -) -> DeviceListResponse: - """Build the device list response data.""" - configured = {entry.name for entry in entries} - return DeviceListResponse( - configured=[entry.to_dict() for entry in entries], - importable=[ - build_importable_device_dict(dashboard, res) - for res in dashboard.import_result.values() - if res.device_name not in configured - ], - ) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py deleted file mode 100644 index 3b22180b1db..00000000000 --- a/esphome/dashboard/settings.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import hmac -import os -from pathlib import Path -from typing import Any - -from esphome.core import CORE -from esphome.helpers import get_bool_env - -from .util.password import password_hash - -# Sentinel file name used for CORE.config_path when dashboard initializes. -# This ensures .parent returns the config directory instead of root. -_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" - - -class DashboardSettings: - """Settings for the dashboard.""" - - __slots__ = ( - "config_dir", - "password_hash", - "username", - "using_password", - "on_ha_addon", - "cookie_secret", - "absolute_config_dir", - "verbose", - ) - - def __init__(self) -> None: - """Initialize the dashboard settings.""" - self.config_dir: Path = None - self.password_hash: bytes = b"" - self.username: str = "" - self.using_password: bool = False - self.on_ha_addon: bool = False - self.cookie_secret: str | None = None - self.absolute_config_dir: Path | None = None - self.verbose: bool = False - - def parse_args(self, args: Any) -> None: - """Parse the arguments.""" - self.on_ha_addon: bool = args.ha_addon - password = args.password or os.getenv("PASSWORD") or "" - if not self.on_ha_addon: - self.username = args.username or os.getenv("USERNAME") or "" - self.using_password = bool(password) - if self.using_password: - self.password_hash = password_hash(password) - self.config_dir = Path(args.configuration) - self.absolute_config_dir = self.config_dir.resolve() - self.verbose = args.verbose - # Set to a sentinel file so .parent gives us the config directory. - # Previously this was `os.path.join(self.config_dir, ".")` which worked because - # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent - # normalizes to Path("/config") first, then .parent returns Path("/"), breaking - # secret resolution. Using a sentinel file ensures .parent gives the correct directory. - CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE - - @property - def relative_url(self) -> str: - return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/" - - @property - def status_use_mqtt(self) -> bool: - return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") - - @property - def using_ha_addon_auth(self) -> bool: - if not self.on_ha_addon: - return False - return not get_bool_env("DISABLE_HA_AUTHENTICATION") - - @property - def using_auth(self) -> bool: - return self.using_password or self.using_ha_addon_auth - - @property - def streamer_mode(self) -> bool: - return get_bool_env("ESPHOME_STREAMER_MODE") - - def check_password(self, username: str, password: str) -> bool: - if not self.using_auth: - return True - # Compare in constant running time (to prevent timing attacks) - username_matches = hmac.compare_digest( - username.encode("utf-8"), self.username.encode("utf-8") - ) - password_matches = hmac.compare_digest( - self.password_hash, password_hash(password) - ) - return username_matches and password_matches - - def rel_path(self, *args: Any) -> Path: - """Return a path relative to the ESPHome config folder.""" - joined_path = self.config_dir / Path(*args) - # Raises ValueError if not relative to ESPHome config folder - joined_path.resolve().relative_to(self.absolute_config_dir) - return joined_path diff --git a/esphome/dashboard/status/__init__.py b/esphome/dashboard/status/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py deleted file mode 100644 index 9da9bb8f01b..00000000000 --- a/esphome/dashboard/status/mdns.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import typing - -from zeroconf import AddressResolver, IPVersion - -from esphome.address_cache import normalize_hostname -from esphome.zeroconf import ( - ESPHOME_SERVICE_TYPE, - AsyncEsphomeZeroconf, - DashboardBrowser, - DashboardImportDiscovery, - DashboardStatus, - DiscoveredImport, -) - -from ..const import SENTINEL, DashboardEvent -from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state -from ..models import build_importable_device_dict - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -class MDNSStatus: - """Class that updates the mdns status.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the MDNSStatus class.""" - super().__init__() - self.aiozc: AsyncEsphomeZeroconf | None = None - # This is the current mdns state for each host (True, False, None) - self.host_mdns_state: dict[str, bool | None] = {} - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - def async_setup(self) -> bool: - """Set up the MDNSStatus class.""" - try: - self.aiozc = AsyncEsphomeZeroconf() - except OSError as e: - _LOGGER.warning( - "Failed to initialize zeroconf, will fallback to ping: %s", e - ) - return False - return True - - async def async_resolve_host(self, host_name: str) -> list[str] | None: - """Resolve a host name to an address in a thread-safe manner.""" - if aiozc := self.aiozc: - return await aiozc.async_resolve_host(host_name) - return None - - def get_cached_addresses(self, host_name: str) -> list[str] | None: - """Get cached addresses for a host without triggering resolution. - - Returns None if not in cache or no zeroconf available. - """ - if not self.aiozc: - _LOGGER.debug("No zeroconf instance available for %s", host_name) - return None - - # Normalize hostname and get the base name - normalized = normalize_hostname(host_name) - base_name = normalized.partition(".")[0] - - # Try to load from zeroconf cache without triggering resolution - resolver_name = f"{base_name}.local." - info = AddressResolver(resolver_name) - # Let zeroconf use its own current time for cache checking - if info.load_from_cache(self.aiozc.zeroconf): - addresses = info.parsed_scoped_addresses(IPVersion.All) - _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) - return addresses - _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) - return None - - def _on_import_update(self, name: str, discovered: DiscoveredImport | None) -> None: - """Handle importable device updates.""" - if discovered is None: - # Device removed - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": name} - ) - else: - # Device added - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - {"device": build_importable_device_dict(self.dashboard, discovered)}, - ) - - async def async_refresh_hosts(self) -> None: - """Refresh the hosts to track.""" - dashboard = self.dashboard - host_mdns_state = self.host_mdns_state - entries = dashboard.entries - poll_names: dict[str, set[DashboardEntry]] = {} - for entry in entries.async_all(): - if entry.no_mdns: - continue - # If we just adopted/imported this host, we likely - # already have a state for it, so we should make sure - # to set it so the dashboard shows it as online - if entry.loaded_integrations and "api" not in entry.loaded_integrations: - # No api available so we have to poll since - # the device won't respond to a request to ._esphomelib._tcp.local. - poll_names.setdefault(entry.name, set()).add(entry) - elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL: - self._async_set_state(entry, online) - if poll_names and self.aiozc: - results = await asyncio.gather( - *(self.aiozc.async_resolve_host(name) for name in poll_names) - ) - for name, address_list in zip(poll_names, results, strict=True): - result = bool(address_list) - host_mdns_state[name] = result - for entry in poll_names[name]: - self._async_set_state(entry, result) - - def _async_set_state(self, entry: DashboardEntry, result: bool | None) -> None: - """Set the state of an entry.""" - state = bool_to_entry_state(result, EntryStateSource.MDNS) - if result: - # If we can reach it via mDNS, we always set it online - # since its the fastest source if its working - self.dashboard.entries.async_set_state(entry, state) - else: - # However if we can't reach it via mDNS - # we only set it to offline if the state is unknown - # or from mDNS - self.dashboard.entries.async_set_state_if_source(entry, state) - - async def async_run(self) -> None: - """Run the mdns status.""" - dashboard = self.dashboard - entries = dashboard.entries - host_mdns_state = self.host_mdns_state - - def on_update(dat: dict[str, bool | None]) -> None: - """Update the entry state.""" - for name, result in dat.items(): - host_mdns_state[name] = result - if matching_entries := entries.get_by_name(name): - for entry in matching_entries: - self._async_set_state(entry, result) - - stat = DashboardStatus(on_update) - - imports = DashboardImportDiscovery(self._on_import_update) - dashboard.import_result = imports.import_state - - browser = DashboardBrowser( - self.aiozc.zeroconf, - ESPHOME_SERVICE_TYPE, - [stat.browser_callback, imports.browser_callback], - ) - - ping_request = dashboard.ping_request - while not dashboard.stop_event.is_set(): - await self.async_refresh_hosts() - await ping_request.wait() - ping_request.clear() - - await browser.async_cancel() - await self.aiozc.async_close() - self.aiozc = None diff --git a/esphome/dashboard/status/mqtt.py b/esphome/dashboard/status/mqtt.py deleted file mode 100644 index c3e48838497..00000000000 --- a/esphome/dashboard/status/mqtt.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import binascii -import json -import os -import threading -import typing - -from esphome import mqtt - -from ..entries import EntryStateSource, bool_to_entry_state - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -class MqttStatusThread(threading.Thread): - """Status thread to get the status of the devices via MQTT.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the status thread.""" - super().__init__() - self.dashboard = dashboard - - def run(self) -> None: - """Run the status thread.""" - dashboard = self.dashboard - entries = dashboard.entries - current_entries = entries.all() - - config = mqtt.config_from_env() - topic = "esphome/discover/#" - - def on_message(client, userdata, msg): - payload = msg.payload.decode(errors="backslashreplace") - if len(payload) > 0: - data = json.loads(payload) - if "name" not in data: - return - if matching_entries := entries.get_by_name(data["name"]): - for entry in matching_entries: - # Only override state if we don't have a state from another source - # or we have a state from MQTT and the device is reachable - entries.set_state_if_online_or_source( - entry, bool_to_entry_state(True, EntryStateSource.MQTT) - ) - - def on_connect(client, userdata, flags, return_code): - client.publish("esphome/discover", None, retain=False) - - mqttid = str(binascii.hexlify(os.urandom(6)).decode()) - - client = mqtt.prepare( - config, - [topic], - on_message, - on_connect, - None, - None, - f"esphome-dashboard-{mqttid}", - ) - client.loop_start() - - while not dashboard.stop_event.wait(2): - current_entries = entries.all() - # will be set to true on on_message - for entry in current_entries: - # Only override state if we don't have a state from another source - entries.set_state_if_source( - entry, bool_to_entry_state(False, EntryStateSource.MQTT) - ) - - client.publish("esphome/discover", None, retain=False) - dashboard.mqtt_ping_request.wait() - dashboard.mqtt_ping_request.clear() - - client.disconnect() - client.loop_stop() diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py deleted file mode 100644 index eb69fbb9b3f..00000000000 --- a/esphome/dashboard/status/ping.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -import typing -from typing import cast - -from icmplib import Host, SocketPermissionError, async_ping - -from ..const import MAX_EXECUTOR_WORKERS -from ..entries import ( - DashboardEntry, - EntryState, - EntryStateSource, - ReachableState, - bool_to_entry_state, -) -from ..util.itertools import chunked - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -_LOGGER = logging.getLogger(__name__) - -GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2) - -DNS_FAILURE_STATE = EntryState(ReachableState.DNS_FAILURE, EntryStateSource.PING) - -MIN_PING_INTERVAL = 5 # ensure we don't ping too often - - -class PingStatus: - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the PingStatus class.""" - super().__init__() - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - async def async_run(self) -> None: - """Run the ping status.""" - dashboard = self.dashboard - entries = dashboard.entries - privileged = await _can_use_icmp_lib_with_privilege() - if privileged is None: - _LOGGER.warning("Cannot use icmplib because privileges are insufficient") - return - - while not dashboard.stop_event.is_set(): - # Only ping if the dashboard is open - await dashboard.ping_request.wait() - dashboard.ping_request.clear() - iteration_start = time.monotonic() - current_entries = dashboard.entries.async_all() - to_ping: list[DashboardEntry] = [] - - for entry in current_entries: - if entry.address is None: - # No address or we already have a state from another source - # so no need to ping - continue - if ( - entry.state.reachable is ReachableState.ONLINE - and entry.state.source - not in (EntryStateSource.PING, EntryStateSource.UNKNOWN) - ): - # If we already have a state from another source and - # it's online, we don't need to ping - continue - to_ping.append(entry) - - # Resolve DNS for all entries - entries_with_addresses: dict[DashboardEntry, list[str]] = {} - for ping_group in chunked(to_ping, GROUP_SIZE): - ping_group = cast(list[DashboardEntry], ping_group) - now_monotonic = time.monotonic() - dns_results = await asyncio.gather( - *( - dashboard.dns_cache.async_resolve(entry.address, now_monotonic) - for entry in ping_group - ), - return_exceptions=True, - ) - - for entry, result in zip(ping_group, dns_results, strict=True): - if isinstance(result, Exception): - # Only update state if its unknown or from ping - # so we don't mark it as offline if we have a state - # from mDNS or MQTT - entries.async_set_state_if_source(entry, DNS_FAILURE_STATE) - continue - if isinstance(result, BaseException): - raise result - entries_with_addresses[entry] = result - - # Ping all entries with valid addresses - for ping_group in chunked(entries_with_addresses.items(), GROUP_SIZE): - entry_addresses = cast(tuple[DashboardEntry, list[str]], ping_group) - - results = await asyncio.gather( - *( - async_ping(addresses[0], privileged=privileged) - for _, addresses in entry_addresses - ), - return_exceptions=True, - ) - - for entry_address, result in zip(entry_addresses, results, strict=True): - if isinstance(result, Exception): - ping_result = False - elif isinstance(result, BaseException): - raise result - else: - host: Host = result - ping_result = host.is_alive - entry: DashboardEntry = entry_address[0] - # If we can reach it via ping, we always set it - # online, however if we can't reach it via ping - # we only set it to offline if the state is unknown - # or from ping - entries.async_set_state_if_online_or_source( - entry, - bool_to_entry_state(ping_result, EntryStateSource.PING), - ) - - if not dashboard.stop_event.is_set(): - iteration_duration = time.monotonic() - iteration_start - if iteration_duration < MIN_PING_INTERVAL: - await asyncio.sleep(MIN_PING_INTERVAL - iteration_duration) - - -async def _can_use_icmp_lib_with_privilege() -> None | bool: - """Verify we can create a raw socket.""" - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) - except SocketPermissionError: - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=False) - except SocketPermissionError: - _LOGGER.debug( - "Cannot use icmplib because privileges are insufficient to create the" - " socket" - ) - return None - - _LOGGER.debug("Using icmplib in privileged=False mode") - return False - - _LOGGER.debug("Using icmplib in privileged=True mode") - return True diff --git a/esphome/dashboard/util/__init__.py b/esphome/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/esphome/dashboard/util/itertools.py b/esphome/dashboard/util/itertools.py deleted file mode 100644 index 54e95ef802d..00000000000 --- a/esphome/dashboard/util/itertools.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from functools import partial -from itertools import islice -from typing import Any - - -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) diff --git a/esphome/dashboard/util/password.py b/esphome/dashboard/util/password.py deleted file mode 100644 index e7ea28c25d5..00000000000 --- a/esphome/dashboard/util/password.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -import hashlib - - -def password_hash(password: str) -> bytes: - """Create a hash of a password to transform it to a fixed-length digest. - - Note this is not meant for secure storage, but for securely comparing passwords. - """ - return hashlib.sha256(password.encode()).digest() diff --git a/esphome/dashboard/util/subprocess.py b/esphome/dashboard/util/subprocess.py deleted file mode 100644 index 583dd116e35..00000000000 --- a/esphome/dashboard/util/subprocess.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Iterable - - -async def async_system_command_status(command: Iterable[str]) -> bool: - """Run a system command checking only the status.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - close_fds=False, - ) - await process.wait() - return process.returncode == 0 - - -async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]: - """Run a system command and return a tuple of returncode, stdout, stderr.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - close_fds=False, - ) - stdout, stderr = await process.communicate() - await process.wait() - return process.returncode, stdout, stderr diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py deleted file mode 100644 index bdf9abfdb9b..00000000000 --- a/esphome/dashboard/util/text.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Back-compat shim for ``friendly_name_slugify``. - -The function moved to :mod:`esphome.helpers` so it survives the legacy -dashboard's eventual removal — see the -``esphome.helpers.friendly_name_slugify`` docstring. This module -re-exports the name so existing -``from esphome.dashboard.util.text import friendly_name_slugify`` -imports keep working while downstream consumers migrate. -""" - -from __future__ import annotations - -from esphome.helpers import friendly_name_slugify - -__all__ = ["friendly_name_slugify"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py deleted file mode 100644 index f5203efe9c6..00000000000 --- a/esphome/dashboard/web_server.py +++ /dev/null @@ -1,1645 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import binascii -from collections.abc import Callable, Iterable -import contextlib -import datetime -import functools -from functools import partial -import gzip -import hashlib -import importlib -import json -import logging -import os -from pathlib import Path -import secrets -import shutil -import subprocess -import threading -import time -from typing import TYPE_CHECKING, Any, TypeVar -from urllib.parse import urlparse - -import tornado -import tornado.concurrent -import tornado.gen -import tornado.httpserver -import tornado.httputil -import tornado.ioloop -import tornado.iostream -from tornado.log import access_log -import tornado.netutil -import tornado.process -import tornado.queues -import tornado.web -import tornado.websocket -import voluptuous as vol -import yaml -from yaml.nodes import Node - -from esphome import const, yaml_util -from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses -from esphome.platformio import toolchain -from esphome.storage_json import ( - StorageJSON, - archive_storage_path, - ext_storage_path, - trash_storage_path, -) -from esphome.util import get_serial_ports, shlex_quote -from esphome.yaml_util import FastestAvailableSafeLoader - -from ..helpers import write_file -from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent -from .core import DASHBOARD, ESPHomeDashboard, Event -from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool -from .models import build_device_list_response -from .util.subprocess import async_run_system_command -from .util.text import friendly_name_slugify - -if TYPE_CHECKING: - from requests import Response - -_LOGGER = logging.getLogger(__name__) - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -COOKIE_AUTHENTICATED_YES = b"yes" - -AUTH_COOKIE_NAME = "authenticated" - - -settings = DASHBOARD.settings - - -def template_args() -> dict[str, Any]: - version = const.__version__ - if "b" in version: - docs_link = "https://beta.esphome.io/" - elif "dev" in version: - docs_link = "https://next.esphome.io/" - else: - docs_link = "https://www.esphome.io/" - - return { - "version": version, - "docs_link": docs_link, - "get_static_file_url": get_static_file_url, - "relative_url": settings.relative_url, - "streamer_mode": settings.streamer_mode, - "config_dir": settings.config_dir, - } - - -T = TypeVar("T", bound=Callable[..., Any]) - - -def authenticated(func: T) -> T: - @functools.wraps(func) - def decorator(self, *args: Any, **kwargs: Any): - if not is_authenticated(self): - self.redirect("./login") - return None - return func(self, *args, **kwargs) - - return decorator - - -def is_authenticated(handler: BaseHandler) -> bool: - """Check if the request is authenticated.""" - if settings.on_ha_addon: - # Handle ingress - disable auth on ingress port - # X-HA-Ingress is automatically stripped on the non-ingress server in nginx - header = handler.request.headers.get("X-HA-Ingress", "NO") - if str(header) == "YES": - return True - - if settings.using_auth: - if auth_header := handler.request.headers.get("Authorization"): - assert isinstance(auth_header, str) - if auth_header.startswith("Basic "): - try: - auth_decoded = base64.b64decode(auth_header[6:]).decode() - username, password = auth_decoded.split(":", 1) - except (binascii.Error, ValueError, UnicodeDecodeError): - return False - return settings.check_password(username, password) - return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES - - return True - - -def bind_config(func): - def decorator(self, *args, **kwargs): - configuration = self.get_argument("configuration") - kwargs = kwargs.copy() - kwargs["configuration"] = configuration - return func(self, *args, **kwargs) - - return decorator - - -# pylint: disable=abstract-method -class BaseHandler(tornado.web.RequestHandler): - pass - - -def websocket_class(cls): - # pylint: disable=protected-access - if not hasattr(cls, "_message_handlers"): - cls._message_handlers = {} - - for method in cls.__dict__.values(): - if hasattr(method, "_message_handler"): - cls._message_handlers[method._message_handler] = method - - return cls - - -def websocket_method(name): - def wrap(fn): - # pylint: disable=protected-access - fn._message_handler = name - return fn - - return wrap - - -class CheckOriginMixin: - """Mixin to handle WebSocket origin checks for reverse proxy setups.""" - - def check_origin(self, origin: str) -> bool: - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - - -@websocket_class -class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """Base class for ESPHome websocket commands.""" - - def __init__( - self, - application: tornado.web.Application, - request: tornado.httputil.HTTPServerRequest, - **kwargs: Any, - ) -> None: - """Initialize the websocket.""" - super().__init__(application, request, **kwargs) - self._proc = None - self._queue = None - self._is_closed = False - # Windows doesn't support non-blocking pipes, - # use Popen() with a reading thread instead - self._use_popen = os.name == "nt" - - def open(self, *args: str, **kwargs: str) -> None: - """Handle new WebSocket connection.""" - # Ensure messages from the subprocess are sent immediately - # to avoid a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - @authenticated - async def on_message( # pylint: disable=invalid-overridden-method - self, message: str - ) -> None: - # Since tornado 4.5, on_message is allowed to be a coroutine - # Messages are always JSON, 500 when not - json_message = json.loads(message) - type_ = json_message["type"] - # pylint: disable=no-member - handlers = type(self)._message_handlers - if type_ not in handlers: - _LOGGER.warning("Requested unknown message type %s", type_) - return - - await handlers[type_](self, json_message) - - @websocket_method("spawn") - async def handle_spawn(self, json_message: dict[str, Any]) -> None: - if self._proc is not None: - # spawn can only be called once - return - command = await self.build_command(json_message) - _LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command)) - - if self._use_popen: - self._queue = tornado.queues.Queue() - # pylint: disable=consider-using-with - self._proc = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - close_fds=False, - ) - stdout_thread = threading.Thread(target=self._stdout_thread) - stdout_thread.daemon = True - stdout_thread.start() - else: - self._proc = tornado.process.Subprocess( - command, - stdout=tornado.process.Subprocess.STREAM, - stderr=subprocess.STDOUT, - stdin=tornado.process.Subprocess.STREAM, - close_fds=False, - ) - self._proc.set_exit_callback(self._proc_on_exit) - - tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout) - - @property - def is_process_active(self) -> bool: - return self._proc is not None and self._proc.returncode is None - - @websocket_method("stdin") - async def handle_stdin(self, json_message: dict[str, Any]) -> None: - if not self.is_process_active: - return - text: str = json_message["data"] - data = text.encode("utf-8", "replace") - _LOGGER.debug("< stdin: %s", data) - self._proc.stdin.write(data) - - @tornado.gen.coroutine - def _redirect_stdout(self) -> None: - reg = b"[\n\r]" - - while True: - try: - if self._use_popen: - data: bytes = yield self._queue.get() - if data is None: - self._proc_on_exit(self._proc.poll()) - break - else: - data: bytes = yield self._proc.stdout.read_until_regex(reg) - except tornado.iostream.StreamClosedError: - break - - text = data.decode("utf-8", "replace") - _LOGGER.debug("> stdout: %s", text) - self.write_message({"event": "line", "data": text}) - - def _stdout_thread(self) -> None: - if not self._use_popen: - return - line = b"" - cr = False - while True: - data = self._proc.stdout.read(1) - if data: - if data == b"\r": - cr = True - elif data == b"\n": - self._queue.put_nowait(line + b"\n") - line = b"" - cr = False - elif cr: - self._queue.put_nowait(line + b"\r") - line = data - cr = False - else: - line += data - if self._proc.poll() is not None: - break - self._proc.wait(1.0) - self._queue.put_nowait(None) - - def _proc_on_exit(self, returncode: int) -> None: - if not self._is_closed: - # Check if the proc was not forcibly closed - _LOGGER.info("Process exited with return code %s", returncode) - self.write_message({"event": "exit", "code": returncode}) - self.close() - - def on_close(self) -> None: - # Check if proc exists (if 'start' has been run) - if self.is_process_active: - _LOGGER.debug("Terminating process") - if self._use_popen: - self._proc.terminate() - else: - self._proc.proc.terminate() - # Shutdown proc on WS close - self._is_closed = True - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - raise NotImplementedError - - -def build_cache_arguments( - entry: DashboardEntry | None, - dashboard: ESPHomeDashboard, - now: float, -) -> list[str]: - """Build cache arguments for passing to CLI. - - Args: - entry: Dashboard entry for the configuration - dashboard: Dashboard instance with cache access - now: Current monotonic time for DNS cache expiry checks - - Returns: - List of cache arguments to pass to CLI - """ - cache_args: list[str] = [] - - if not entry: - return cache_args - - _LOGGER.debug( - "Building cache for entry (address=%s, name=%s)", - entry.address, - entry.name, - ) - - def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: - """Add a cache entry to the command arguments.""" - if not addresses: - return - normalized = hostname.rstrip(".").lower() - cache_args.extend( - [ - f"--{cache_type}-address-cache", - f"{normalized}={','.join(sort_ip_addresses(addresses))}", - ] - ) - - # Check entry.address for cached addresses - if use_address := entry.address: - if use_address.endswith(".local"): - # mDNS cache for .local addresses - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(use_address) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "mdns") - # DNS cache for non-.local addresses - elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): - _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "dns") - - # Check entry.name if we haven't already cached via address - # For mDNS devices, entry.name typically doesn't have .local suffix - if entry.name and not use_address: - mdns_name = ( - f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name - ) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(mdns_name) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) - add_cache_entry(mdns_name, cached, "mdns") - - return cache_args - - -class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): - """Base class for commands that require a port.""" - - async def build_device_command( - self, args: list[str], json_message: dict[str, Any] - ) -> list[str]: - """Build the command to run.""" - dashboard = DASHBOARD - entries = dashboard.entries - configuration = json_message["configuration"] - config_file = settings.rel_path(configuration) - port = json_message["port"] - - # Build cache arguments to pass to CLI - cache_args: list[str] = [] - - if ( - port == "OTA" # pylint: disable=too-many-boolean-expressions - and (entry := entries.get(config_file)) - and entry.loaded_integrations - and "api" in entry.loaded_integrations - ): - cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) - - # Cache arguments must come before the subcommand - cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeLogsHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - cmd = await self.build_device_command(["logs"], json_message) - if json_message.get("no_states"): - cmd.append("--no-states") - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeRenameHandler(EsphomeCommandWebSocket): - old_name: str - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - self.old_name = json_message["configuration"] - return [ - *DASHBOARD_COMMAND, - "rename", - config_file, - json_message["newName"], - ] - - def _proc_on_exit(self, returncode): - super()._proc_on_exit(returncode) - - if returncode != 0: - return - - # Remove the old ping result from the cache - entries = DASHBOARD.entries - if entry := entries.get(self.old_name): - entries.async_set_state(entry, UNKNOWN_STATE) - - -class EsphomeUploadHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["upload"], json_message) - - -class EsphomeRunHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["run"], json_message) - - -class EsphomeCompileHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "compile"] - if json_message.get("only_generate", False): - command.append("--only-generate") - command.append(config_file) - return command - - -class EsphomeValidateHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "config", config_file] - if not settings.streamer_mode: - command.append("--show-secrets") - return command - - -class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] - - -class EsphomeCleanAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - clean_build_dir = json_message.get("clean_build_dir", True) - if clean_build_dir: - return [*DASHBOARD_COMMAND, "clean-all", settings.config_dir] - return [*DASHBOARD_COMMAND, "clean-all"] - - -class EsphomeCleanHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean", config_file] - - -class EsphomeVscodeHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"] - - -class EsphomeAceEditorHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir] - - -class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] - - -# Dashboard polling constants -DASHBOARD_POLL_INTERVAL = 2 # seconds -DASHBOARD_ENTRIES_UPDATE_INTERVAL = 10 # seconds -DASHBOARD_ENTRIES_UPDATE_ITERATIONS = ( - DASHBOARD_ENTRIES_UPDATE_INTERVAL // DASHBOARD_POLL_INTERVAL -) - - -class DashboardSubscriber: - """Manages dashboard event polling task lifecycle based on active subscribers.""" - - def __init__(self) -> None: - """Initialize the dashboard subscriber.""" - self._subscribers: set[DashboardEventsWebSocket] = set() - self._event_loop_task: asyncio.Task | None = None - self._refresh_event: asyncio.Event = asyncio.Event() - - def subscribe(self, subscriber: DashboardEventsWebSocket) -> Callable[[], None]: - """Subscribe to dashboard updates and start event loop if needed.""" - self._subscribers.add(subscriber) - if not self._event_loop_task or self._event_loop_task.done(): - self._event_loop_task = asyncio.create_task(self._event_loop()) - _LOGGER.info("Started dashboard event loop") - return partial(self._unsubscribe, subscriber) - - def _unsubscribe(self, subscriber: DashboardEventsWebSocket) -> None: - """Unsubscribe from dashboard updates and stop event loop if no subscribers.""" - self._subscribers.discard(subscriber) - if ( - not self._subscribers - and self._event_loop_task - and not self._event_loop_task.done() - ): - self._event_loop_task.cancel() - self._event_loop_task = None - _LOGGER.info("Stopped dashboard event loop - no subscribers") - - def request_refresh(self) -> None: - """Signal the polling loop to refresh immediately.""" - self._refresh_event.set() - - async def _event_loop(self) -> None: - """Run the event polling loop while there are subscribers.""" - dashboard = DASHBOARD - entries_update_counter = 0 - - while self._subscribers: - # Signal that we need ping updates (non-blocking) - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - - # Check if it's time to update entries or if refresh was requested - entries_update_counter += 1 - if ( - entries_update_counter >= DASHBOARD_ENTRIES_UPDATE_ITERATIONS - or self._refresh_event.is_set() - ): - entries_update_counter = 0 - await dashboard.entries.async_request_update_entries() - # Clear the refresh event if it was set - self._refresh_event.clear() - - # Wait for either timeout or refresh event - try: - async with asyncio.timeout(DASHBOARD_POLL_INTERVAL): - await self._refresh_event.wait() - # If we get here, refresh was requested - continue loop immediately - except TimeoutError: - # Normal timeout - continue with regular polling - pass - - -# Global dashboard subscriber instance -DASHBOARD_SUBSCRIBER = DashboardSubscriber() - - -@websocket_class -class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """WebSocket handler for real-time dashboard events.""" - - _event_listeners: list[Callable[[], None]] | None = None - _dashboard_unsubscribe: Callable[[], None] | None = None - - async def get(self, *args: str, **kwargs: str) -> None: - """Handle WebSocket upgrade request.""" - if not is_authenticated(self): - self.set_status(401) - self.finish("Unauthorized") - return - await super().get(*args, **kwargs) - - async def open(self, *args: str, **kwargs: str) -> None: # pylint: disable=invalid-overridden-method - """Handle new WebSocket connection.""" - # Ensure messages are sent immediately to avoid - # a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - # Update entries first - await DASHBOARD.entries.async_request_update_entries() - # Send initial state - self._send_initial_state() - # Subscribe to events - self._subscribe_to_events() - # Subscribe to dashboard updates - self._dashboard_unsubscribe = DASHBOARD_SUBSCRIBER.subscribe(self) - _LOGGER.debug("Dashboard status WebSocket opened") - - def _send_initial_state(self) -> None: - """Send initial device list and ping status.""" - entries = DASHBOARD.entries.async_all() - - # Send initial state - self._safe_send_message( - { - "event": DashboardEvent.INITIAL_STATE, - "data": { - "devices": build_device_list_response(DASHBOARD, entries), - "ping": { - entry.filename: entry_state_to_bool(entry.state) - for entry in entries - }, - }, - } - ) - - def _subscribe_to_events(self) -> None: - """Subscribe to dashboard events.""" - async_add_listener = DASHBOARD.bus.async_add_listener - # Subscribe to all events - self._event_listeners = [ - async_add_listener( - DashboardEvent.ENTRY_STATE_CHANGED, self._on_entry_state_changed - ), - async_add_listener( - DashboardEvent.ENTRY_ADDED, - self._make_entry_handler(DashboardEvent.ENTRY_ADDED), - ), - async_add_listener( - DashboardEvent.ENTRY_REMOVED, - self._make_entry_handler(DashboardEvent.ENTRY_REMOVED), - ), - async_add_listener( - DashboardEvent.ENTRY_UPDATED, - self._make_entry_handler(DashboardEvent.ENTRY_UPDATED), - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, self._on_importable_added - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - self._on_importable_removed, - ), - ] - - def _on_entry_state_changed(self, event: Event) -> None: - """Handle entry state change event.""" - entry = event.data["entry"] - state = event.data["state"] - self._safe_send_message( - { - "event": DashboardEvent.ENTRY_STATE_CHANGED, - "data": { - "filename": entry.filename, - "name": entry.name, - "state": entry_state_to_bool(state), - }, - } - ) - - def _make_entry_handler( - self, event_type: DashboardEvent - ) -> Callable[[Event], None]: - """Create an entry event handler.""" - - def handler(event: Event) -> None: - self._safe_send_message( - {"event": event_type, "data": {"device": event.data["entry"].to_dict()}} - ) - - return handler - - def _on_importable_added(self, event: Event) -> None: - """Handle importable device added event.""" - # Don't send if device is already configured - device_name = event.data.get("device", {}).get("name") - if device_name and DASHBOARD.entries.get_by_name(device_name): - return - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_ADDED, "data": event.data} - ) - - def _on_importable_removed(self, event: Event) -> None: - """Handle importable device removed event.""" - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_REMOVED, "data": event.data} - ) - - def _safe_send_message(self, message: dict[str, Any]) -> None: - """Send a message to the WebSocket client, ignoring closed errors.""" - with contextlib.suppress(tornado.websocket.WebSocketClosedError): - self.write_message(json.dumps(message)) - - def on_message(self, message: str) -> None: - """Handle incoming WebSocket messages.""" - _LOGGER.debug("WebSocket received message: %s", message) - try: - data = json.loads(message) - except json.JSONDecodeError as err: - _LOGGER.debug("Failed to parse WebSocket message: %s", err) - return - - event = data.get("event") - _LOGGER.debug("WebSocket message event: %s", event) - if event == DashboardEvent.PING: - # Send pong response for client ping - _LOGGER.debug("Received client ping, sending pong") - self._safe_send_message({"event": DashboardEvent.PONG}) - elif event == DashboardEvent.REFRESH: - # Signal the polling loop to refresh immediately - _LOGGER.debug("Received refresh request, signaling polling loop") - DASHBOARD_SUBSCRIBER.request_refresh() - - def on_close(self) -> None: - """Handle WebSocket close.""" - # Unsubscribe from dashboard updates - if self._dashboard_unsubscribe: - self._dashboard_unsubscribe() - self._dashboard_unsubscribe = None - - # Unsubscribe from events - for remove_listener in self._event_listeners or []: - remove_listener() - - _LOGGER.debug("Dashboard status WebSocket closed") - - -class SerialPortRequestHandler(BaseHandler): - @authenticated - async def get(self) -> None: - ports = await asyncio.get_running_loop().run_in_executor(None, get_serial_ports) - data = [] - for port in ports: - desc = port.description - if port.path == "/dev/ttyAMA0": - desc = "UART pins on GPIO header" - split_desc = desc.split(" - ") - if len(split_desc) == 2 and split_desc[0] == split_desc[1]: - # Some serial ports repeat their values - desc = split_desc[0] - data.append({"port": port.path, "desc": desc}) - data.append({"port": "OTA", "desc": "Over-The-Air"}) - data.sort(key=lambda x: x["port"], reverse=True) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - - -class WizardRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome import wizard - - kwargs = { - k: v - for k, v in json.loads(self.request.body.decode()).items() - if k - in ( - "type", - "name", - "platform", - "board", - "ssid", - "psk", - "password", - "file_content", - ) - } - if not kwargs["name"]: - self.set_status(422) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Name is required"})) - return - - if "type" not in kwargs: - # Default to basic wizard type for backwards compatibility - kwargs["type"] = "basic" - - kwargs["friendly_name"] = kwargs["name"] - kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - if kwargs["type"] == "basic": - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() - elif kwargs["type"] == "upload": - try: - kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode( - "utf-8" - ) - except (binascii.Error, UnicodeDecodeError): - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": "The uploaded file is not correctly encoded."}) - ) - return - elif kwargs["type"] != "empty": - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": f"Invalid wizard type specified: {kwargs['type']}"} - ) - ) - return - filename = f"{kwargs['name']}.yaml" - destination = settings.rel_path(filename) - - # Check if destination file already exists - if destination.exists(): - self.set_status(409) # Conflict status code - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": f"Configuration file '{filename}' already exists"}) - ) - self.finish() - return - - success = wizard.wizard_write(path=destination, **kwargs) - if success: - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() - else: - self.set_status(500) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": "Failed to write configuration, see logs for details"} - ) - ) - self.finish() - - -class ImportRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome.components.dashboard_import import import_config - - dashboard = DASHBOARD - args = json.loads(self.request.body.decode()) - try: - name = args["name"] - friendly_name = args.get("friendly_name") - encryption = args.get("encryption", False) - - imported_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == name - ), - None, - ) - - if imported_device is not None: - network = imported_device.network - if friendly_name is None: - friendly_name = imported_device.friendly_name - else: - network = const.CONF_WIFI - - import_config( - settings.rel_path(f"{name}.yaml"), - name, - friendly_name, - args["project_name"], - args["package_import_url"], - network, - encryption, - ) - # Make sure the device gets marked online right away - dashboard.ping_request.set() - except FileExistsError: - self.set_status(500) - self.write("File already exists") - return - except ValueError as e: - _LOGGER.error(e) - self.set_status(422) - self.write("Invalid package url") - return - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": f"{name}.yaml"})) - self.finish() - - -class IgnoreDeviceRequestHandler(BaseHandler): - @authenticated - async def post(self) -> None: - dashboard = DASHBOARD - try: - args = json.loads(self.request.body.decode()) - device_name = args["name"] - ignore = args["ignore"] - except (json.JSONDecodeError, KeyError): - self.set_status(400) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Invalid payload"})) - return - - ignored_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == device_name - ), - None, - ) - - if ignored_device is None: - self.set_status(404) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Device not found"})) - return - - if ignore: - dashboard.ignored_devices.add(ignored_device.device_name) - else: - dashboard.ignored_devices.discard(ignored_device.device_name) - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, dashboard.save_ignored_devices) - - self.set_status(204) - self.finish() - - -class DownloadListRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - loop = asyncio.get_running_loop() - try: - downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid as exc: - _LOGGER.exception("Error while fetching downloads", exc_info=exc) - self.send_error(404) - return - if downloads_json is None: - _LOGGER.error("Configuration %s not found", configuration) - self.send_error(404) - return - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(downloads_json) - self.finish() - - def _get(self, configuration: str | None = None) -> dict[str, Any] | None: - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - return None - - try: - config = yaml_util.load_yaml(settings.rel_path(configuration)) - - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) - - do_external_components_pass(config) - except vol.Invalid: - _LOGGER.info("Could not parse `external_components`, skipping") - - from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS - - downloads: list[dict[str, Any]] = [] - platform: str = storage_json.target_platform.lower() - - if platform.upper() in ESP32_VARIANTS: - platform = "esp32" - elif platform in ( - const.PLATFORM_RTL87XX, - const.PLATFORM_BK72XX, - const.PLATFORM_LN882X, - ): - platform = "libretiny" - - try: - module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = module.get_download_types - except AttributeError as exc: - raise ValueError(f"Unknown platform {platform}") from exc - downloads = get_download_types(storage_json) - return json.dumps(downloads) - - -class DownloadBinaryRequestHandler(BaseHandler): - def _load_file(self, path: str, compressed: bool) -> bytes: - """Load a file from disk and compress it if requested.""" - with Path(path).open("rb") as f: - data = f.read() - if compressed: - return gzip.compress(data, 9) - return data - - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Download a binary file.""" - loop = asyncio.get_running_loop() - compressed = self.get_argument("compressed", "0") == "1" - - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - self.send_error(404) - return - - # fallback to type=, but prioritize file= - file_name = self.get_argument("type", None) - file_name = self.get_argument("file", file_name) - if file_name is None or not file_name.strip(): - self.send_error(400) - return - # get requested download name, or build it based on filename - download_name = self.get_argument( - "download", - f"{storage_json.name}-{file_name}", - ) - - if storage_json.firmware_bin_path is None: - self.send_error(404) - return - - base_dir = storage_json.firmware_bin_path.parent.resolve() - path = base_dir.joinpath(file_name).resolve() - try: - path.relative_to(base_dir) - except ValueError: - self.send_error(403) - return - - if not path.is_file(): - args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] - rc, stdout, _ = await async_run_system_command(args) - - if rc != 0: - self.send_error(404 if rc == 2 else 500) - return - - idedata = toolchain.IDEData(json.loads(stdout)) - - found = False - for image in idedata.extra_flash_images: - if image.path.as_posix().endswith(file_name): - path = image.path - download_name = file_name - found = True - break - - if not found: - self.send_error(404) - return - - download_name = download_name + ".gz" if compressed else download_name - - self.set_header("Content-Type", "application/octet-stream") - self.set_header( - "Content-Disposition", f'attachment; filename="{download_name}"' - ) - self.set_header("Cache-Control", "no-cache") - if not Path(path).is_file(): - self.send_error(404) - return - - data = await loop.run_in_executor(None, self._load_file, path, compressed) - self.write(data) - - self.finish() - - -class EsphomeVersionHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.set_header("Content-Type", "application/json") - self.write(json.dumps({"version": const.__version__})) - self.finish() - - -class ListDevicesHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - self.write(json.dumps(build_device_list_response(dashboard, entries))) - - -class MainRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - begin = bool(self.get_argument("begin", False)) - if settings.using_password: - # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 - else: - self.clear_cookie("_xsrf") - - self.render( - "index.template.html", - begin=begin, - **template_args(), - login_enabled=settings.using_password, - ) - - -class PrometheusServiceDiscoveryHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - sd = [] - for entry in entries: - if entry.web_port is None: - continue - labels = { - "__meta_name": entry.name, - "__meta_esp_platform": entry.target_platform, - "__meta_esphome_version": entry.storage.esphome_version, - } - for integration in entry.storage.loaded_integrations: - labels[f"__meta_integration_{integration}"] = "true" - sd.append( - { - "targets": [ - f"{entry.address}:{entry.web_port}", - ], - "labels": labels, - } - ) - self.write(json.dumps(sd)) - - -class BoardsRequestHandler(BaseHandler): - @authenticated - def get(self, platform: str) -> None: - # filter all ESP32 variants by requested platform - if platform.startswith("esp32"): - from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS - - boards = { - k: v - for k, v in ESP32_BOARDS.items() - if v[const.KEY_VARIANT] == platform.upper() - } - elif platform == const.PLATFORM_ESP8266: - from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS - - boards = ESP8266_BOARDS - elif platform == const.PLATFORM_RP2040: - from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS - - boards = RP2040_BOARDS - elif platform == const.PLATFORM_BK72XX: - from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS - - boards = BK72XX_BOARDS - elif platform == const.PLATFORM_LN882X: - from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS - - boards = LN882X_BOARDS - elif platform == const.PLATFORM_RTL87XX: - from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS - - boards = RTL87XX_BOARDS - else: - raise ValueError(f"Unknown platform {platform}") - - # map to a {board_name: board_title} dict - platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()} - # sort by board title - boards_items = sorted(platform_boards.items(), key=lambda item: item[1]) - output = [{"items": dict(boards_items)}] - - self.set_header("content-type", "application/json") - self.write(json.dumps(output)) - - -class PingRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - dashboard = DASHBOARD - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - self.set_header("content-type", "application/json") - - self.write( - json.dumps( - { - entry.filename: entry_state_to_bool(entry.state) - for entry in dashboard.entries.async_all() - } - ) - ) - - -class InfoRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - yaml_path = settings.rel_path(configuration) - dashboard = DASHBOARD - entry = dashboard.entries.get(yaml_path) - - if not entry or entry.storage is None: - self.set_status(404) - return - - self.set_header("content-type", "application/json") - self.write(entry.storage.to_json()) - - -class EditRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Get the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - content = await loop.run_in_executor( - None, self._read_file, filename, configuration - ) - if content is not None: - self.set_header("Content-Type", "application/yaml") - self.write(content) - - def _read_file(self, filename: str, configuration: str) -> bytes | None: - """Read a file and return the content as bytes.""" - try: - with Path(filename).open(encoding="utf-8") as f: - return f.read() - except FileNotFoundError: - if configuration in const.SECRETS_FILES: - return "" - self.set_status(404) - return None - - @authenticated - @bind_config - async def post(self, configuration: str | None = None) -> None: - """Write the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, write_file, filename, self.request.body) - # Ensure the StorageJSON is updated as well - DASHBOARD.entries.async_schedule_storage_json_update(filename) - self.set_status(200) - - -class ArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - storage_path = ext_storage_path(configuration) - - archive_path = archive_storage_path() - mkdir_p(archive_path) - shutil.move(config_file, archive_path / configuration) - - storage_json = StorageJSON.load(storage_path) - if storage_json is not None and storage_json.build_path: - # Delete build folder (if exists) - shutil.rmtree(storage_json.build_path, ignore_errors=True) - - -class UnArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - archive_path = archive_storage_path() - shutil.move(archive_path / configuration, config_file) - - -class LoginHandler(BaseHandler): - def get(self) -> None: - if is_authenticated(self): - self.redirect("./") - else: - self.render_login_page() - - def render_login_page(self, error: str | None = None) -> None: - self.render( - "login.template.html", - error=error, - ha_addon=settings.using_ha_addon_auth, - has_username=bool(settings.username), - **template_args(), - ) - - def _make_supervisor_auth_request(self) -> Response: - """Make a request to the supervisor auth endpoint.""" - import requests - - headers = {"X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN")} - data = { - "username": self.get_argument("username", ""), - "password": self.get_argument("password", ""), - } - return requests.post( - "http://supervisor/auth", headers=headers, json=data, timeout=30 - ) - - async def post_ha_addon_login(self) -> None: - loop = asyncio.get_running_loop() - try: - req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.warning("Error during Hass.io auth request: %s", err) - self.set_status(500) - self.render_login_page(error="Internal server error") - return - - if req.status_code == 200: - self._set_authenticated() - self.redirect("/") - return - self.set_status(401) - self.render_login_page(error="Invalid username or password") - - def _set_authenticated(self) -> None: - """Set the authenticated cookie.""" - self.set_secure_cookie(AUTH_COOKIE_NAME, COOKIE_AUTHENTICATED_YES) - - def post_native_login(self) -> None: - username = self.get_argument("username", "") - password = self.get_argument("password", "") - if settings.check_password(username, password): - self._set_authenticated() - self.redirect("./") - return - error_str = ( - "Invalid username or password" if settings.username else "Invalid password" - ) - self.set_status(401) - self.render_login_page(error=error_str) - - async def post(self): - if settings.using_ha_addon_auth: - await self.post_ha_addon_login() - else: - self.post_native_login() - - -class LogoutHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.clear_cookie(AUTH_COOKIE_NAME) - self.redirect("./login") - - -class SecretKeysRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - filename = None - - for secret_filename in const.SECRETS_FILES: - relative_filename = settings.rel_path(secret_filename) - if relative_filename.is_file(): - filename = relative_filename - break - - if filename is None: - self.send_error(404) - return - - secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False)) - - self.set_header("content-type", "application/json") - self.write(json.dumps(secret_keys)) - - -class SafeLoaderIgnoreUnknown(FastestAvailableSafeLoader): - def ignore_unknown(self, node: Node) -> str: - return f"{node.tag} {node.value}" - - def construct_yaml_binary(self, node: Node) -> str: - return super().construct_yaml_binary(node).decode("ascii") - - -SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown) -SafeLoaderIgnoreUnknown.add_constructor( - "tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary -) - - -class JsonConfigRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - filename = settings.rel_path(configuration) - if not filename.is_file(): - self.send_error(404) - return - - args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] - - rc, stdout, stderr = await async_run_system_command(args) - - if rc != 0: - self.set_status(422) - self.write(stderr) - return - - data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - self.finish() - - -def get_base_frontend_path() -> Path: - if ENV_DEV not in os.environ: - import esphome_dashboard - - return esphome_dashboard.where() - - static_path = os.environ[ENV_DEV] - if not static_path.endswith("/"): - static_path += "/" - - # This path can be relative, so resolve against the root or else templates don't work - path = Path.cwd() / static_path / "esphome_dashboard" - return path.resolve() - - -def get_static_path(*args: Iterable[str]) -> Path: - return get_base_frontend_path() / "static" / Path(*args) - - -@functools.cache -def get_static_file_url(name: str) -> str: - base = f"./static/{name}" - - if ENV_DEV in os.environ: - return base - - # Module imports can't deduplicate if stuff added to url - if name == "js/esphome/index.js": - import esphome_dashboard - - return base.replace("index.js", esphome_dashboard.entrypoint()) - - path = get_static_path(name) - hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8] - return f"{base}?hash={hash_}" - - -def make_app(debug: bool | None = None) -> tornado.web.Application: - if debug is None: - debug = get_bool_env(ENV_DEV) - - def log_function(handler: tornado.web.RequestHandler) -> None: - if handler.get_status() < 400: - log_method = access_log.info - - if isinstance(handler, SerialPortRequestHandler) and not debug: - return - if isinstance(handler, PingRequestHandler) and not debug: - return - elif handler.get_status() < 500: - log_method = access_log.warning - else: - log_method = access_log.error - - request_time = 1000.0 * handler.request.request_time() - # pylint: disable=protected-access - log_method( - "%d %s %.2fms", - handler.get_status(), - handler._request_summary(), - request_time, - ) - - class StaticFileHandler(tornado.web.StaticFileHandler): - def get_cache_time( - self, path: str, modified: datetime.datetime | None, mime_type: str - ) -> int: - """Override to customize cache control behavior.""" - if debug: - return 0 - # Assets that are hashed have ?hash= in the URL, all javascript - # filenames hashed so we can cache them for a long time - if "hash" in self.request.arguments or "/javascript" in mime_type: - return self.CACHE_MAX_AGE - return super().get_cache_time(path, modified, mime_type) - - app_settings = { - "debug": debug, - "cookie_secret": settings.cookie_secret, - "log_function": log_function, - "websocket_ping_interval": 30.0, - "template_path": get_base_frontend_path(), - "xsrf_cookies": settings.using_password, - } - rel = settings.relative_url - return tornado.web.Application( - [ - (f"{rel}", MainRequestHandler), - (f"{rel}login", LoginHandler), - (f"{rel}logout", LogoutHandler), - (f"{rel}logs", EsphomeLogsHandler), - (f"{rel}upload", EsphomeUploadHandler), - (f"{rel}run", EsphomeRunHandler), - (f"{rel}compile", EsphomeCompileHandler), - (f"{rel}validate", EsphomeValidateHandler), - (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), - (f"{rel}clean-all", EsphomeCleanAllHandler), - (f"{rel}clean", EsphomeCleanHandler), - (f"{rel}vscode", EsphomeVscodeHandler), - (f"{rel}ace", EsphomeAceEditorHandler), - (f"{rel}update-all", EsphomeUpdateAllHandler), - (f"{rel}info", InfoRequestHandler), - (f"{rel}edit", EditRequestHandler), - (f"{rel}downloads", DownloadListRequestHandler), - (f"{rel}download.bin", DownloadBinaryRequestHandler), - (f"{rel}serial-ports", SerialPortRequestHandler), - (f"{rel}ping", PingRequestHandler), - (f"{rel}delete", ArchiveRequestHandler), - (f"{rel}undo-delete", UnArchiveRequestHandler), - (f"{rel}archive", ArchiveRequestHandler), - (f"{rel}unarchive", UnArchiveRequestHandler), - (f"{rel}wizard", WizardRequestHandler), - (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), - (f"{rel}devices", ListDevicesHandler), - (f"{rel}events", DashboardEventsWebSocket), - (f"{rel}import", ImportRequestHandler), - (f"{rel}secret_keys", SecretKeysRequestHandler), - (f"{rel}json-config", JsonConfigRequestHandler), - (f"{rel}rename", EsphomeRenameHandler), - (f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler), - (f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler), - (f"{rel}version", EsphomeVersionHandler), - (f"{rel}ignore-device", IgnoreDeviceRequestHandler), - ], - **app_settings, - ) - - -def start_web_server( - app: tornado.web.Application, - socket: str | None, - address: str | None, - port: int | None, - config_dir: str, -) -> None: - """Start the web server listener.""" - - trash_path = trash_storage_path() - if trash_path.is_dir() and trash_path.exists(): - _LOGGER.info("Renaming 'trash' folder to 'archive'") - archive_path = archive_storage_path() - shutil.move(trash_path, archive_path) - - if socket is None: - _LOGGER.info( - "Starting dashboard web server on http://%s:%s and configuration dir %s...", - address, - port, - config_dir, - ) - app.listen(port, address) - return - - _LOGGER.info( - "Starting dashboard web server on unix socket %s and configuration dir %s...", - socket, - config_dir, - ) - server = tornado.httpserver.HTTPServer(app) - socket = tornado.netutil.bind_unix_socket(socket, mode=0o666) - server.add_socket(socket) diff --git a/esphome/helpers.py b/esphome/helpers.py index ef7e2d0b93f..62dfd0fb098 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -124,14 +124,8 @@ def slugify(value: str) -> str: def friendly_name_slugify(value: str) -> str: """Convert a friendly name to a slug with dashes instead of underscores. - Used by: - - esphome.dashboard.web_server (legacy dashboard) - - device-builder (esphome/device-builder) — slugifies friendly names - into the YAML filename / device name during adoption + wizard flows. - - Lives here rather than in ``esphome.dashboard.util.text`` so it - survives the legacy dashboard's eventual removal. - The dashboard module re-exports this name as a back-compat shim. + Used by device-builder (esphome/device-builder), which slugifies friendly + names into the YAML filename / device name during adoption + wizard flows. Coordinate with the device-builder team before changing the slugification rules — the mapping must stay stable so existing on-disk filenames keep matching across releases. diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3bdda1a9a1c..f754673b792 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,14 +71,10 @@ def _to_path_if_not_none(value: str | None) -> Path | None: class StorageJSON: """Persisted device metadata sidecar. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — reads/writes the same - JSON file as the legacy dashboard so a single config_dir can be - shared between the two during the transition. The schema - (``storage_version``, field names, types) must stay backwards - compatible — coordinate with the device-builder team before - adding required fields or changing semantics of existing ones. + Used by device-builder (esphome/device-builder), which reads/writes this + JSON file. The schema (``storage_version``, field names, types) must stay + backwards compatible — coordinate with the device-builder team before + adding required fields or changing semantics of existing ones. """ def __init__( diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index e4b9abb976d..04075ec4c1f 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -62,14 +62,12 @@ TXT_RECORD_VERSION = b"version" class DiscoveredImport: """An importable device discovered via mDNS ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — surfaces these as - "discovered devices" on the new dashboard's adoption flow. + Used by device-builder (esphome/device-builder), which surfaces these as + "discovered devices" on its adoption flow. Fields are populated from TXT records on the broadcast service info (see :class:`DashboardImportDiscovery`). Coordinate before - adding/removing fields — both consumers persist them. + adding/removing fields — the consumer persists them. """ friendly_name: str | None @@ -87,11 +85,9 @@ class DashboardBrowser(AsyncServiceBrowser): class DashboardImportDiscovery: """Track importable devices announcing on ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — wired up alongside - the dashboard's own ``ServiceBrowser`` to populate the - "Discovered devices" panel and the adoption flow. + Used by device-builder (esphome/device-builder), which wires it up + alongside its own ``ServiceBrowser`` to populate the + "Discovered devices" panel and the adoption flow. The class maintains ``import_state: dict[str, DiscoveredImport]`` keyed by the mDNS service name. ``on_update`` is invoked with @@ -262,11 +258,9 @@ async def async_resolve_hosts( class AsyncEsphomeZeroconf(AsyncZeroconf): """ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — drives both the live - mDNS browser and the per-sweep ``async_resolve_host`` fallback - for non-API devices that don't broadcast esphomelib. + Used by device-builder (esphome/device-builder), which drives both the live + mDNS browser and the per-sweep ``async_resolve_host`` fallback + for non-API devices that don't broadcast esphomelib. Coordinate before adding required constructor args or changing the ``async_resolve_host`` signature — device-builder calls it diff --git a/requirements.txt b/requirements.txt index 06a383b00aa..b01b2a4c6ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,15 +3,12 @@ voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 -icmplib==3.0.4 -tornado==6.5.7 tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.3.0 click==8.3.3 -esphome-dashboard==20260425.0 aioesphomeapi==45.3.1 zeroconf==0.149.16 puremagic==1.30 diff --git a/script/ci-custom.py b/script/ci-custom.py index cbc54ce55d3..6c5ad5bb69f 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -259,14 +259,7 @@ def lint_executable_bit(fname: Path) -> str | None: return None -@lint_content_find_check( - "\t", - only_first=True, - exclude=[ - "esphome/dashboard/static/ace.js", - "esphome/dashboard/static/ext-searchbox.js", - ], -) +@lint_content_find_check("\t", only_first=True) def lint_tabs(fname, line, col, content): return "File contains tab character. Please convert tabs to spaces." diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/dashboard/common.py b/tests/dashboard/common.py deleted file mode 100644 index f84c03aad82..00000000000 --- a/tests/dashboard/common.py +++ /dev/null @@ -1,6 +0,0 @@ -import pathlib - - -def get_fixture_path(filename: str) -> pathlib.Path: - """Get path of fixture.""" - return pathlib.Path(__file__).parent.joinpath("fixtures", filename) diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py deleted file mode 100644 index f95adef7498..00000000000 --- a/tests/dashboard/conftest.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Common fixtures for dashboard tests.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, Mock - -import pytest -import pytest_asyncio - -from esphome.dashboard.core import ESPHomeDashboard -from esphome.dashboard.entries import DashboardEntries - - -@pytest.fixture -def mock_settings(tmp_path: Path) -> MagicMock: - """Create mock dashboard settings.""" - settings = MagicMock() - settings.config_dir = str(tmp_path) - settings.absolute_config_dir = tmp_path - return settings - - -@pytest.fixture -def mock_dashboard(mock_settings: MagicMock) -> Mock: - """Create a mock dashboard.""" - dashboard = Mock(spec=ESPHomeDashboard) - dashboard.settings = mock_settings - dashboard.entries = Mock() - dashboard.entries.async_all.return_value = [] - dashboard.stop_event = Mock() - dashboard.stop_event.is_set.return_value = True - dashboard.ping_request = Mock() - dashboard.ignored_devices = set() - dashboard.bus = Mock() - dashboard.bus.async_fire = Mock() - return dashboard - - -@pytest_asyncio.fixture -async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries: - """Create a DashboardEntries instance for testing.""" - return DashboardEntries(mock_dashboard) diff --git a/tests/dashboard/fixtures/conf/pico.yaml b/tests/dashboard/fixtures/conf/pico.yaml deleted file mode 100644 index cf5b5b75bf5..00000000000 --- a/tests/dashboard/fixtures/conf/pico.yaml +++ /dev/null @@ -1,47 +0,0 @@ -substitutions: - name: picoproxy - friendly_name: Pico Proxy - -esphome: - name: ${name} - friendly_name: ${friendly_name} - project: - name: esphome.bluetooth-proxy - version: "1.0" - -esp32: - board: esp32dev - framework: - type: esp-idf - -wifi: - ap: - -api: -logger: -ota: -improv_serial: - -dashboard_import: - package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main - -button: - - platform: factory_reset - id: resetf - - platform: safe_mode - name: Safe Mode Boot - entity_category: diagnostic - -sensor: - - platform: template - id: pm11 - name: "pm 1.0µm" - lambda: return 1.0; - - platform: template - id: pm251 - name: "pm 2.5µm" - lambda: return 2.5; - - platform: template - id: pm101 - name: "pm 10µm" - lambda: return 10; diff --git a/tests/dashboard/status/__init__.py b/tests/dashboard/status/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py deleted file mode 100644 index f7c49920799..00000000000 --- a/tests/dashboard/status/test_dns.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Unit tests for esphome.dashboard.dns module.""" - -from __future__ import annotations - -import time -from unittest.mock import AsyncMock, patch - -from icmplib import NameLookupError -import pytest - -from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper - - -@pytest.fixture -def dns_cache_fixture() -> DNSCache: - """Create a DNSCache instance.""" - return DNSCache() - - -def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when hostname is not in cache.""" - now = time.monotonic() - result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) - assert result is None - - -def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache entry is expired.""" - now = time.monotonic() - # Add entry that's already expired - dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None - # Expired entry should still be in cache (not removed by get_cached_addresses) - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with valid cache entry.""" - now = time.monotonic() - # Add entry that expires in 60 seconds - dns_cache_fixture._cache["example.com"] = ( - now + 60, - ["192.168.1.10", "192.168.1.11"], - ) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["192.168.1.10", "192.168.1.11"] - # Entry should still be in cache - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_hostname_normalization( - dns_cache_fixture: DNSCache, -) -> None: - """Test get_cached_addresses normalizes hostname.""" - now = time.monotonic() - # Add entry with lowercase hostname - dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) - - # Test with various forms - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ - "192.168.1.10" - ] - - -def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with IPv6 addresses.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["2001:db8::1", "fe80::1"] - - -def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with empty address list.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, []) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == [] - - -def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache contains an exception.""" - now = time.monotonic() - # Store an exception (from failed resolution) - dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None # Should return None for exceptions - - -def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: - """Test that get_cached_addresses never calls async_resolve.""" - now = time.monotonic() - - with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: - # Test non-cached - result = dns_cache_fixture.get_cached_addresses("uncached.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test expired - dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("expired.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test valid - dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("valid.com", now) - assert result == ["192.168.1.10"] - mock_resolve.assert_not_called() - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_ip_address() -> None: - """Test _async_resolve_wrapper returns IP address directly.""" - result = await _async_resolve_wrapper("192.168.1.10") - assert result == ["192.168.1.10"] - - result = await _async_resolve_wrapper("2001:db8::1") - assert result == ["2001:db8::1"] - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_success() -> None: - """Test _async_resolve_wrapper falls back to bare hostname for .local.""" - mock_resolve = AsyncMock() - # First call (device.local) fails, second call (device) succeeds - mock_resolve.side_effect = [ - NameLookupError("device.local"), - ["192.168.1.50"], - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - assert mock_resolve.call_count == 2 - mock_resolve.assert_any_call("device.local") - mock_resolve.assert_any_call("device") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_both_fail() -> None: - """Test _async_resolve_wrapper returns exception when both fail.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.local") - mock_resolve.side_effect = [ - original_exception, - NameLookupError("device"), - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - # Should return the original exception, not the fallback exception - assert result is original_exception - assert mock_resolve.call_count == 2 - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_non_local_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback for non-.local hostnames.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.example.com") - mock_resolve.side_effect = original_exception - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.example.com") - - assert result is original_exception - # Should only try the original hostname, no fallback - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.example.com") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_success_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback when .local succeeds.""" - mock_resolve = AsyncMock(return_value=["192.168.1.50"]) - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - # Should only try once since it succeeded - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.local") diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py deleted file mode 100644 index 56c6d254cfe..00000000000 --- a/tests/dashboard/status/test_mdns.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Unit tests for esphome.dashboard.status.mdns module.""" - -from __future__ import annotations - -from unittest.mock import Mock, patch - -import pytest -import pytest_asyncio -from zeroconf import AddressResolver, IPVersion - -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.status.mdns import MDNSStatus -from esphome.zeroconf import DiscoveredImport - - -@pytest_asyncio.fixture -async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: - """Create an MDNSStatus instance in async context.""" - # We're in an async context so get_running_loop will work - return MDNSStatus(mock_dashboard) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when no zeroconf instance is available.""" - mdns_status.aiozc = None - result = mdns_status.get_cached_addresses("device.local") - assert result is None - - -@pytest.mark.asyncio -async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is not in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = False - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result is None - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is found in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["192.168.1.10", "fe80::1"] - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with hostname having trailing dot.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local.") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with uppercase hostname.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("DEVICE.LOCAL") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with simple hostname (no domain).""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device") - assert result == ["192.168.1.10"] - # Should append .local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning only IPv6 addresses.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["fe80::1", "2001:db8::1"] - - -@pytest.mark.asyncio -async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning empty list from cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = [] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == [] - - -@pytest.mark.asyncio -async def test_async_setup_success(mock_dashboard: Mock) -> None: - """Test successful async_setup.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None - - -@pytest.mark.asyncio -async def test_async_setup_failure(mock_dashboard: Mock) -> None: - """Test async_setup with OSError.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None - - -@pytest.mark.asyncio -async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is added.""" - # Create a DiscoveredImport object - discovered = DiscoveredImport( - device_name="test_device", - friendly_name="Test Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Call _on_import_update with a device - mdns_status._on_import_update("test_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - assert "device" in call_args[0][1] - device_data = call_args[0][1]["device"] - assert device_data["name"] == "test_device" - assert device_data["friendly_name"] == "Test Device" - assert device_data["project_name"] == "test_project" - assert device_data["ignored"] is False - - -@pytest.mark.asyncio -async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is ignored.""" - # Add device to ignored list - mdns_status.dashboard.ignored_devices.add("ignored_device") - - # Create a DiscoveredImport object for ignored device - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Call _on_import_update with an ignored device - mdns_status._on_import_update("ignored_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - device_data = call_args[0][1]["device"] - assert device_data["name"] == "ignored_device" - assert device_data["ignored"] is True - - -@pytest.mark.asyncio -async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is removed.""" - # Call _on_import_update with None (device removed) - mdns_status._on_import_update("removed_device", None) - - # Should fire IMPORTABLE_DEVICE_REMOVED event - mdns_status.dashboard.bus.async_fire.assert_called_once_with( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"} - ) diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py deleted file mode 100644 index 9a3a776b28c..00000000000 --- a/tests/dashboard/test_entries.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Tests for dashboard entries Path-related functionality.""" - -from __future__ import annotations - -import os -from pathlib import Path -import tempfile -from unittest.mock import Mock - -import pytest - -from esphome.core import CORE -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.entries import DashboardEntries, DashboardEntry - - -def create_cache_key() -> tuple[int, int, float, int]: - """Helper to create a valid DashboardCacheKeyType.""" - return (0, 0, 0.0, 0) - - -@pytest.fixture(autouse=True) -def setup_core(): - """Set up CORE for testing.""" - with tempfile.TemporaryDirectory() as tmpdir: - CORE.config_path = Path(tmpdir) / "test.yaml" - yield - CORE.reset() - - -def test_dashboard_entry_path_initialization() -> None: - """Test DashboardEntry initializes with path correctly.""" - test_path = Path("/test/config/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.cache_key == cache_key - - -def test_dashboard_entry_path_with_absolute_path() -> None: - """Test DashboardEntry handles absolute paths.""" - # Use a truly absolute path for the platform - test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.path.is_absolute() - - -def test_dashboard_entry_path_with_relative_path() -> None: - """Test DashboardEntry handles relative paths.""" - test_path = Path("configs/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert not entry.path.is_absolute() - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_by_path( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test getting entry by path.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Verify the entry was loaded - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - assert entry.path == test_file - - # Also verify get() works with Path - result = dashboard_entries.get(test_file) - assert result == entry - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_nonexistent_path( - dashboard_entries: DashboardEntries, -) -> None: - """Test getting non-existent entry returns None.""" - result = dashboard_entries.get("/nonexistent/path.yaml") - assert result is None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_normalization( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that paths are handled consistently.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_spaces( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with spaces.""" - # Create a test file with spaces in name - test_file = tmp_path / "my device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - assert result.path == test_file - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_special_chars( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with special characters.""" - # Create a test file with special characters - test_file = tmp_path / "device-01_test.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -def test_dashboard_entries_windows_path() -> None: - """Test handling Windows-style paths.""" - test_path = Path(r"C:\Users\test\esphome\device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_to_cache_key_mapping( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test internal entries storage with paths and cache keys.""" - # Create test files - file1 = tmp_path / "device1.yaml" - file2 = tmp_path / "device2.yaml" - file1.write_text("test config 1") - file2.write_text("test config 2") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - # Get entries and verify they have different cache keys - entry1 = dashboard_entries.get(file1) - entry2 = dashboard_entries.get(file2) - - assert entry1 is not None - assert entry2 is not None - assert entry1.cache_key != entry2.cache_key - - -def test_dashboard_entry_path_property() -> None: - """Test that path property returns expected value.""" - test_path = Path("/test/config/device.yaml") - entry = DashboardEntry(test_path, create_cache_key()) - - assert entry.path == test_path - assert isinstance(entry.path, Path) - - -@pytest.mark.asyncio -async def test_dashboard_entries_all_returns_entries_with_paths( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that all() returns entries with their paths intact.""" - # Create test files - files = [ - tmp_path / "device1.yaml", - tmp_path / "device2.yaml", - tmp_path / "device3.yaml", - ] - - for file in files: - file.write_text("test config") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - all_entries = dashboard_entries.async_all() - - assert len(all_entries) == len(files) - retrieved_paths = [entry.path for entry in all_entries] - assert set(retrieved_paths) == set(files) - - -@pytest.mark.asyncio -async def test_async_update_entries_removed_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that removed files trigger ENTRY_REMOVED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - - # Delete the file - test_file.unlink() - - # Second update to detect removal - await dashboard_entries.async_update_entries() - - # Verify entry was removed - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 0 - - # Verify ENTRY_REMOVED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_REMOVED, {"entry": entry} - ) - - -@pytest.mark.asyncio -async def test_async_update_entries_updated_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that modified files trigger ENTRY_UPDATED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - original_cache_key = entry.cache_key - - # Modify the file to change its mtime - test_file.write_text("updated config") - # Explicitly change the mtime to ensure it's different - stat = test_file.stat() - os.utime(test_file, (stat.st_atime, stat.st_mtime + 1)) - - # Second update to detect modification - await dashboard_entries.async_update_entries() - - # Verify entry is still there with updated cache key - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - updated_entry = all_entries[0] - assert updated_entry == entry # Same entry object - assert updated_entry.cache_key != original_cache_key # But cache key updated - - # Verify ENTRY_UPDATED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_UPDATED, {"entry": entry} - ) diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py deleted file mode 100644 index 55776ac7c4d..00000000000 --- a/tests/dashboard/test_settings.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Tests for DashboardSettings (path resolution and authentication).""" - -from __future__ import annotations - -from argparse import Namespace -from pathlib import Path -import tempfile - -import pytest - -from esphome.core import CORE -from esphome.dashboard.settings import DashboardSettings -from esphome.dashboard.util.password import password_hash - - -@pytest.fixture -def dashboard_settings(tmp_path: Path) -> DashboardSettings: - """Create DashboardSettings instance with temp directory.""" - settings = DashboardSettings() - # Resolve symlinks to ensure paths match - resolved_dir = tmp_path.resolve() - settings.config_dir = resolved_dir - settings.absolute_config_dir = resolved_dir - return settings - - -def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with simple relative path.""" - result = dashboard_settings.rel_path("config.yaml") - - expected = dashboard_settings.config_dir / "config.yaml" - assert result == expected - - -def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with multiple path components.""" - result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") - - expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml" - assert result == expected - - -def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None: - """Test rel_path prevents directory traversal.""" - # This should raise ValueError as it tries to go outside config_dir - with pytest.raises(ValueError): - dashboard_settings.rel_path("..", "outside.yaml") - - -def test_rel_path_absolute_path_within_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path that's within config dir.""" - internal_path = dashboard_settings.absolute_config_dir / "internal.yaml" - - internal_path.touch() - result = dashboard_settings.rel_path("internal.yaml") - expected = dashboard_settings.config_dir / "internal.yaml" - assert result == expected - - -def test_rel_path_absolute_path_outside_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path outside config dir raises error.""" - outside_path = "/tmp/outside/config.yaml" - - with pytest.raises(ValueError): - dashboard_settings.rel_path(outside_path) - - -def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with no arguments returns config_dir.""" - result = dashboard_settings.rel_path() - assert result == dashboard_settings.config_dir - - -def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None: - """Test rel_path works with Path objects as arguments.""" - path_obj = Path("subfolder") / "config.yaml" - result = dashboard_settings.rel_path(path_obj) - - expected = dashboard_settings.config_dir / "subfolder" / "config.yaml" - assert result == expected - - -def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None: - """Test rel_path normalizes path separators.""" - # os.path.join normalizes slashes on Windows but preserves them on Unix - # Test that providing components separately gives same result - result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - assert result1 == result2 - - # Also test that the result is as expected - expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml" - assert result1 == expected - - -def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with spaces.""" - result = dashboard_settings.rel_path("my folder", "my config.yaml") - - expected = dashboard_settings.config_dir / "my folder" / "my config.yaml" - assert result == expected - - -def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with special characters.""" - result = dashboard_settings.rel_path("device-01_test", "config.yaml") - - expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml" - assert result == expected - - -def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: - """Test that config_dir can be accessed and used with Path operations.""" - config_path = dashboard_settings.config_dir - - assert config_path.exists() - assert config_path.is_dir() - assert config_path.is_absolute() - - -def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None: - """Test absolute_config_dir is a Path object.""" - assert isinstance(dashboard_settings.absolute_config_dir, Path) - assert dashboard_settings.absolute_config_dir.exists() - assert dashboard_settings.absolute_config_dir.is_dir() - assert dashboard_settings.absolute_config_dir.is_absolute() - - -def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points inside config dir.""" - target = dashboard_settings.absolute_config_dir / "target.yaml" - target.touch() - symlink = dashboard_settings.absolute_config_dir / "link.yaml" - symlink.symlink_to(target) - result = dashboard_settings.rel_path("link.yaml") - expected = dashboard_settings.config_dir / "link.yaml" - assert result == expected - - -def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points outside config dir.""" - with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp: - symlink = dashboard_settings.absolute_config_dir / "external_link.yaml" - symlink.symlink_to(tmp.name) - with pytest.raises(ValueError): - dashboard_settings.rel_path("external_link.yaml") - - -def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles None arguments gracefully.""" - result = dashboard_settings.rel_path("None") - expected = dashboard_settings.config_dir / "None" - assert result == expected - - -def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles numeric arguments.""" - result = dashboard_settings.rel_path("123", "456.789") - expected = dashboard_settings.config_dir / "123" / "456.789" - assert result == expected - - -def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: - """Test that CORE.config_path.parent resolves to config_dir after parse_args. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - The issue was that after switching from os.path to Path: - - Before: os.path.dirname("/config/.") → "/config" - - After: Path("/config/.").parent → Path("/") (normalized first!) - - The fix uses a sentinel file so .parent returns the correct directory: - - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-device.yaml" - device_config.write_text( - "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" - ) - - # Set up dashboard settings with our test config directory - settings = DashboardSettings() - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - settings.parse_args(args) - - # Verify that CORE.config_path.parent correctly points to the config directory - # This is critical for secret resolution in yaml_util.py which does: - # main_config_dir = CORE.config_path.parent - # main_secret_yml = main_config_dir / "secrets.yaml" - assert CORE.config_path.parent == config_dir.resolve() - assert (CORE.config_path.parent / "secrets.yaml").exists() - assert (CORE.config_path.parent / "common.yaml").exists() - - # Verify that CORE.config_path itself uses the sentinel file - assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" - assert not CORE.config_path.exists() # Sentinel file doesn't actually exist - - -@pytest.fixture -def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings: - """Create DashboardSettings with auth configured, based on dashboard_settings.""" - dashboard_settings.username = "admin" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("correctpassword") - return dashboard_settings - - -def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None: - """Test check_password returns True for correct username and password.""" - assert auth_settings.check_password("admin", "correctpassword") is True - - -def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong password.""" - assert auth_settings.check_password("admin", "wrongpassword") is False - - -def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong username.""" - assert auth_settings.check_password("notadmin", "correctpassword") is False - - -def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None: - """Test check_password returns False when both are wrong.""" - assert auth_settings.check_password("notadmin", "wrongpassword") is False - - -def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None: - """Test check_password returns True when auth is not configured.""" - assert dashboard_settings.check_password("anyone", "anything") is True - - -def test_check_password_non_ascii_username( - dashboard_settings: DashboardSettings, -) -> None: - """Test check_password handles non-ASCII usernames without TypeError.""" - dashboard_settings.username = "\u00e9l\u00e8ve" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("pass") - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False - assert dashboard_settings.check_password("other", "pass") is False - - -def test_check_password_ha_addon_no_password( - dashboard_settings: DashboardSettings, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test check_password doesn't crash in HA add-on mode without a password. - - In HA add-on mode, using_ha_addon_auth can be True while using_password - is False, leaving password_hash as b"". This must not raise TypeError - in hmac.compare_digest. - """ - monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False) - dashboard_settings.on_ha_addon = True - dashboard_settings.using_password = False - # password_hash stays as default b"" - assert dashboard_settings.check_password("anyone", "anything") is False diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py deleted file mode 100644 index 0ee841e68c2..00000000000 --- a/tests/dashboard/test_web_server.py +++ /dev/null @@ -1,1889 +0,0 @@ -from __future__ import annotations - -from argparse import Namespace -import asyncio -import base64 -from collections.abc import Generator -from contextlib import asynccontextmanager -import gzip -import json -import os -from pathlib import Path -import sys -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse -from tornado.httpserver import HTTPServer -from tornado.ioloop import IOLoop -from tornado.testing import bind_unused_port -from tornado.websocket import WebSocketClientConnection, websocket_connect - -from esphome import yaml_util -from esphome.core import CORE -from esphome.dashboard import web_server -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.core import DASHBOARD -from esphome.dashboard.entries import ( - DashboardEntry, - EntryStateSource, - bool_to_entry_state, -) -from esphome.dashboard.models import build_importable_device_dict -from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket -from esphome.zeroconf import DiscoveredImport - -from .common import get_fixture_path - - -def get_build_path(base_path: Path, device_name: str) -> Path: - """Get the build directory path for a device. - - This is a test helper that constructs the standard ESPHome build directory - structure. Note: This helper does NOT perform path traversal sanitization - because it's only used in tests where we control the inputs. The actual - web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() - via file_name.replace("..", "").lstrip("/"). - - Args: - base_path: The base temporary path (typically tmp_path from pytest) - device_name: The name of the device (should not contain path separators - in production use, but tests may use it for specific scenarios) - - Returns: - Path to the build directory (.esphome/build/device_name) - """ - return base_path / ".esphome" / "build" / device_name - - -class DashboardTestHelper: - def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: - self.io_loop = io_loop - self.client = client - self.port = port - - async def fetch(self, path: str, **kwargs) -> HTTPResponse: - """Get a response for the given path.""" - if path.lower().startswith(("http://", "https://")): - url = path - else: - url = f"http://127.0.0.1:{self.port}{path}" - future = self.client.fetch(url, raise_error=True, **kwargs) - return await future - - -@pytest.fixture -def mock_async_run_system_command() -> Generator[MagicMock]: - """Fixture to mock async_run_system_command.""" - with patch("esphome.dashboard.web_server.async_run_system_command") as mock: - yield mock - - -@pytest.fixture -def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock trash_storage_path.""" - trash_dir = tmp_path / "trash" - with patch( - "esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir - ) as mock: - yield mock - - -@pytest.fixture -def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock archive_storage_path.""" - archive_dir = tmp_path / "archive" - with patch( - "esphome.dashboard.web_server.archive_storage_path", - return_value=archive_dir, - ) as mock: - yield mock - - -@pytest.fixture -def mock_dashboard_settings() -> Generator[MagicMock]: - """Fixture to mock dashboard settings.""" - with patch("esphome.dashboard.web_server.settings") as mock_settings: - # Set default auth settings to avoid authentication issues - mock_settings.using_auth = False - mock_settings.on_ha_addon = False - yield mock_settings - - -@pytest.fixture -def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock ext_storage_path.""" - with patch("esphome.dashboard.web_server.ext_storage_path") as mock: - mock.return_value = str(tmp_path / "storage.json") - yield mock - - -@pytest.fixture -def mock_storage_json() -> Generator[MagicMock]: - """Fixture to mock StorageJSON.""" - with patch("esphome.dashboard.web_server.StorageJSON") as mock: - yield mock - - -@pytest.fixture -def mock_idedata() -> Generator[MagicMock]: - """Fixture to mock platformio toolchain.IDEData.""" - with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock: - yield mock - - -@pytest_asyncio.fixture() -async def dashboard() -> DashboardTestHelper: - sock, port = bind_unused_port() - args = Mock( - ha_addon=True, - configuration=get_fixture_path("conf"), - port=port, - ) - DASHBOARD.settings.parse_args(args) - app = web_server.make_app() - http_server = HTTPServer(app) - http_server.add_sockets([sock]) - await DASHBOARD.async_setup() - os.environ["DISABLE_HA_AUTHENTICATION"] = "1" - assert DASHBOARD.settings.using_password is False - assert DASHBOARD.settings.on_ha_addon is True - assert DASHBOARD.settings.using_auth is False - task = asyncio.create_task(DASHBOARD.async_run()) - # Wait for initial device loading to complete - await DASHBOARD.entries.async_request_update_entries() - client = AsyncHTTPClient() - io_loop = IOLoop(make_current=False) - yield DashboardTestHelper(io_loop, client, port) - task.cancel() - sock.close() - client.close() - io_loop.close() - - -@asynccontextmanager -async def websocket_connection(dashboard: DashboardTestHelper): - """Async context manager for WebSocket connections.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - try: - yield ws - finally: - if ws: - ws.close() - - -@pytest_asyncio.fixture -async def websocket_client(dashboard: DashboardTestHelper) -> WebSocketClientConnection: - """Create a WebSocket connection for testing.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - - # Read and discard initial state message - await ws.read_message() - - yield ws - - if ws: - ws.close() - - -@pytest.mark.asyncio -async def test_main_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/") - assert response.code == 200 - - -@pytest.mark.asyncio -async def test_devices_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/devices") - assert response.code == 200 - assert response.headers["content-type"] == "application/json" - json_data = json.loads(response.body.decode()) - configured_devices = json_data["configured"] - assert len(configured_devices) != 0 - first_device = configured_devices[0] - assert first_device["name"] == "pico" - assert first_device["configuration"] == "pico.yaml" - - -@pytest.mark.asyncio -async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post method with invalid inputs.""" - # Test with missing name (should fail with 422) - body_no_name = json.dumps( - { - "name": "", # Empty name - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_no_name, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - # Test with invalid wizard type (should fail with 422) - body_invalid_type = json.dumps( - { - "name": "test_device", - "type": "invalid_type", - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_invalid_type, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post when config already exists.""" - # Try to create a wizard for existing pico.yaml (should conflict) - body = json.dumps( - { - "name": "pico", # This already exists in fixtures - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 409 - - -@pytest.mark.asyncio -async def test_download_binary_handler_not_found( - dashboard: DashboardTestHelper, -) -> None: - """Test the DownloadBinaryRequestHandler.get with non-existent config.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=nonexistent.yaml", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_file_param( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get without file parameter.""" - # Mock storage to exist, but still should fail without file param - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=pico.yaml", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with existing binary file.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"fake firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"fake firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - assert "test_device-firmware.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_compressed( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with compression.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - original_content = b"fake firmware content for compression test" - firmware_file.write_bytes(original_content) - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", - method="GET", - ) - assert response.code == 200 - # Decompress and verify content - decompressed = gzip.decompress(response.body) - assert decompressed == original_content - assert response.headers["Content-Type"] == "application/octet-stream" - assert "firmware.bin.gz" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_custom_download_name( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with custom download name.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_idedata_fallback( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_async_run_system_command: MagicMock, - mock_storage_json: MagicMock, - mock_idedata: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" - # Create build directory but no bootloader file initially - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware") - - # Create bootloader file that idedata will find - bootloader_file = tmp_path / "bootloader.bin" - bootloader_file.write_bytes(b"bootloader content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock idedata response - mock_image = Mock() - mock_image.path = bootloader_file - mock_idedata_instance = Mock() - mock_idedata_instance.extra_flash_images = [mock_image] - mock_idedata.return_value = mock_idedata_instance - - # Mock async_run_system_command to return idedata JSON - mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=bootloader.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"bootloader content" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). - - This is a regression test for issue #11343 where the Path migration broke - downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. - - The issue was that with_name() doesn't accept path separators: - - Before: path = storage_json.firmware_bin_path.with_name(file_name) - ValueError: Invalid name 'zephyr/zephyr.uf2' - - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) - Works correctly with subdirectory paths - """ - # Create a fake nRF52 build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "nrf52-device") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - # Create the main firmware binary (would be in build root) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main firmware") - - # Create the UF2 file in zephyr subdirectory (nRF52 specific) - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"nRF52 UF2 firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "nrf52-device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request the UF2 file with subdirectory path - response = await dashboard.fetch( - "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nRF52 UF2 firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - # Download name should be device-name + full file path - assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file_url_encoded( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. - - Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly - decoded and handled, and that custom download names work with subdirectories. - """ - # Create a fake build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "test") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request with URL-encoded path and custom download name - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize( - ("attack_path", "expected_code"), - [ - pytest.param("../../../secrets.yaml", 403, id="basic_traversal"), - pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"), - pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"), - pytest.param("/etc/passwd", 403, id="absolute_path"), - pytest.param("//etc/passwd", 403, id="double_slash_absolute"), - pytest.param( - "....//secrets.yaml", - # On Windows, Path.resolve() treats "..." and "...." as parent - # traversal (like ".."), so the path escapes base_dir -> 403. - # On Unix, "...." is a literal directory name that stays inside - # base_dir but doesn't exist -> 404. - 403 if sys.platform == "win32" else 404, - id="multiple_dots", - ), - ], -) -async def test_download_binary_handler_path_traversal_protection( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, - attack_path: str, - expected_code: int, -) -> None: - """Test that DownloadBinaryRequestHandler prevents path traversal attacks. - - Verifies that attempts to escape the build directory via '..' are rejected - using resolve()/relative_to() validation. Tests multiple attack vectors. - Real traversals that escape the base directory get 403. Paths like '....' - that resolve inside the base directory but don't exist get 404. - """ - # Create build structure - build_dir = get_build_path(tmp_path, "test") - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - - # Create a sensitive file outside the build directory that should NOT be accessible - sensitive_file = tmp_path / "secrets.yaml" - sensitive_file.write_bytes(b"secret: my_secret_password") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock async_run_system_command so paths that pass validation but don't exist - # return 404 deterministically without spawning a real subprocess. - with ( - patch( - "esphome.dashboard.web_server.async_run_system_command", - new_callable=AsyncMock, - return_value=(2, "", ""), - ), - pytest.raises(HTTPClientError) as exc_info, - ): - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={attack_path}", - method="GET", - ) - assert exc_info.value.code == expected_code - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_firmware_bin_path( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, -) -> None: - """Test that download returns 404 when firmware_bin_path is None. - - This covers configs created by StorageJSON.from_wizard() where no - firmware has been compiled yet. - """ - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = None - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"]) -async def test_download_binary_handler_empty_file_name( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, - file_value: str, -) -> None: - """Test that download returns 400 for empty or whitespace-only file names.""" - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = Path("/fake/firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={file_value}", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_multiple_subdirectory_levels( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test downloading files from multiple subdirectory levels. - - Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. - """ - # Create nested directory structure - build_dir = get_build_path(tmp_path, "test") - nested_dir = build_dir / "build" / "output" - nested_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main") - - nested_file = nested_dir / "firmware.bin" - nested_file.write_bytes(b"nested firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nested firmware content" - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_invalid_file( - dashboard: DashboardTestHelper, -) -> None: - """Test the EditRequestHandler.post with non-yaml file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/edit?configuration=test.txt", - method="POST", - body=b"content", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_existing( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the EditRequestHandler.post with existing yaml file.""" - # Create a temporary yaml file to edit (don't modify fixtures) - test_file = tmp_path / "test_edit.yaml" - test_file.write_text("esphome:\n name: original\n") - - # Configure the mock settings - mock_dashboard_settings.rel_path.return_value = test_file - mock_dashboard_settings.absolute_config_dir = test_file.parent - - new_content = "esphome:\n name: modified\n" - response = await dashboard.fetch( - "/edit?configuration=test_edit.yaml", - method="POST", - body=new_content.encode(), - ) - assert response.code == 200 - - # Verify the file was actually modified - assert test_file.read_text() == new_content - - -@pytest.mark.asyncio -async def test_unarchive_request_handler( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - tmp_path: Path, -) -> None: - """Test the UnArchiveRequestHandler.post method.""" - # Set up an archived file - archive_dir = mock_archive_storage_path.return_value - archive_dir.mkdir(parents=True, exist_ok=True) - archived_file = archive_dir / "archived.yaml" - archived_file.write_text("test content") - - # Set up the destination path where the file should be moved - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - destination_file = config_dir / "archived.yaml" - mock_dashboard_settings.rel_path.return_value = destination_file - - response = await dashboard.fetch( - "/unarchive?configuration=archived.yaml", - method="POST", - body=b"", - ) - assert response.code == 200 - - # Verify the file was actually moved from archive to config - assert not archived_file.exists() # File should be gone from archive - assert destination_file.exists() # File should now be in config - assert destination_file.read_text() == "test content" # Content preserved - - -@pytest.mark.asyncio -async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: - """Test the SecretKeysRequestHandler.get when no secrets file exists.""" - # By default, there's no secrets file in the test fixtures - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/secret_keys", method="GET") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_secret_keys_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the SecretKeysRequestHandler.get when secrets file exists.""" - # Create a secrets file in temp directory - secrets_file = tmp_path / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" - ) - - # Configure mock to return our temp secrets file - # Since the file actually exists, os.path.isfile will return True naturally - mock_dashboard_settings.rel_path.return_value = secrets_file - - response = await dashboard.fetch("/secret_keys", method="GET") - assert response.code == 200 - data = json.loads(response.body.decode()) - assert "wifi_ssid" in data - assert "wifi_password" in data - assert "api_key" in data - - -@pytest.mark.asyncio -async def test_json_config_handler( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get method.""" - # This will actually run the esphome config command on pico.yaml - mock_output = json.dumps( - { - "esphome": {"name": "pico"}, - "esp32": {"board": "esp32dev"}, - } - ) - mock_async_run_system_command.return_value = (0, mock_output, "") - - response = await dashboard.fetch( - "/json-config?configuration=pico.yaml", method="GET" - ) - assert response.code == 200 - data = json.loads(response.body.decode()) - assert data["esphome"]["name"] == "pico" - - -@pytest.mark.asyncio -async def test_json_config_handler_invalid_config( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get with invalid config.""" - # Simulate esphome config command failure - mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: - """Test the JsonConfigRequestHandler.get with non-existent file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/json-config?configuration=nonexistent.yaml", method="GET" - ) - assert exc_info.value.code == 404 - - -def test_start_web_server_with_address_port( - tmp_path: Path, - mock_trash_storage_path: MagicMock, - mock_archive_storage_path: MagicMock, -) -> None: - """Test the start_web_server function with address and port.""" - app = Mock() - trash_dir = mock_trash_storage_path.return_value - archive_dir = mock_archive_storage_path.return_value - - # Create trash dir to test migration - trash_dir.mkdir() - (trash_dir / "old.yaml").write_text("old") - - web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) - - # The function calls app.listen directly for non-socket mode - app.listen.assert_called_once_with(6052, "127.0.0.1") - - # Verify trash was moved to archive - assert not trash_dir.exists() - assert archive_dir.exists() - assert (archive_dir / "old.yaml").exists() - - -@pytest.mark.asyncio -async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: - """Test EditRequestHandler.get method.""" - # Test getting a valid yaml file - response = await dashboard.fetch("/edit?configuration=pico.yaml") - assert response.code == 200 - assert response.headers["content-type"] == "application/yaml" - content = response.body.decode() - assert "esphome:" in content # Verify it's a valid ESPHome config - - # Test getting a non-existent file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=nonexistent.yaml") - assert exc_info.value.code == 404 - - # Test getting a non-yaml file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=test.txt") - assert exc_info.value.code == 404 - - # Test path traversal attempt - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=../../../etc/passwd") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_archive_request_handler_post( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post method without storage_json.""" - - # Set up temp directories - config_dir = Path(get_fixture_path("conf")) - archive_dir = tmp_path / "archive" - - # Create a test configuration file - test_config = config_dir / "test_archive.yaml" - test_config.write_text("esphome:\n name: test_archive\n") - - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body="configuration=test_archive.yaml", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - # Verify file was moved to archive - assert not test_config.exists() - assert (archive_dir / "test_archive.yaml").exists() - assert ( - archive_dir / "test_archive.yaml" - ).read_text() == "esphome:\n name: test_archive\n" - - -@pytest.mark.asyncio -async def test_archive_handler_with_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json and build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - build_dir = tmp_path / "build" - build_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - build_folder = build_dir / "test_device" - build_folder.mkdir() - (build_folder / "firmware.bin").write_text("binary content") - (build_folder / ".pioenvs").mkdir() - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = build_folder - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - - assert not build_folder.exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.asyncio -async def test_archive_handler_no_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json but no build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = None - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") -@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") -def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: - """Test the start_web_server function with unix socket.""" - app = Mock() - socket_path = tmp_path / "test.sock" - - # Don't create trash_dir - it doesn't exist, so no migration needed - with ( - patch("tornado.httpserver.HTTPServer") as mock_server_class, - patch("tornado.netutil.bind_unix_socket") as mock_bind, - ): - server = Mock() - mock_server_class.return_value = server - mock_bind.return_value = Mock() - - web_server.start_web_server( - app, str(socket_path), None, None, str(tmp_path / "config") - ) - - mock_server_class.assert_called_once_with(app) - mock_bind.assert_called_once_with(str(socket_path), mode=0o666) - server.add_socket.assert_called_once() - - -def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: - """Test with no entry returns empty list.""" - result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: - """Test with entry but no address or name.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = None - entry.name = None - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: - """Test with .local address that has cached mDNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "device.local" - entry.name = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = [ - "192.168.1.10", - "fe80::1", - ] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "device.local=192.168.1.10,fe80::1", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "device.local" - ) - - -def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: - """Test with non-.local address that has cached DNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "example.com" - entry.name = None - mock_dashboard.dns_cache = Mock() - mock_dashboard.dns_cache.get_cached_addresses.return_value = [ - "93.184.216.34", - "2606:2800:220:1:248:1893:25c8:1946", - ] - - now = 100.0 - result = web_server.build_cache_arguments(entry, mock_dashboard, now) - - # IPv6 addresses are sorted before IPv4 - assert result == [ - "--dns-address-cache", - "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", - ] - mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( - "example.com", now - ) - - -def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: - """Test with name but no address - should check mDNS with .local suffix.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.name = "my-device" - entry.address = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "my-device.local=192.168.1.20", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "my-device.local" - ) - - -@pytest.mark.asyncio -async def test_websocket_connection_initial_state( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection and initial state.""" - async with websocket_connection(dashboard) as ws: - # Should receive initial state with configured and importable devices - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - assert "devices" in data["data"] - assert "configured" in data["data"]["devices"] - assert "importable" in data["data"]["devices"] - - # Check configured devices - configured = data["data"]["devices"]["configured"] - assert len(configured) > 0 - assert configured[0]["name"] == "pico" # From test fixtures - - -@pytest.mark.asyncio -async def test_websocket_ping_pong( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket ping/pong mechanism.""" - # Send ping - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_invalid_json( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket handling of invalid JSON.""" - # Send invalid JSON - await websocket_client.write_message("not valid json {]") - - # Send a valid ping to verify connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong, confirming the connection wasn't closed by invalid JSON - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_authentication_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket authentication when auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = False - - # Try to connect - should be rejected with 401 - url = f"ws://127.0.0.1:{dashboard.port}/events" - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(url) - # Should get HTTP 401 Unauthorized - assert exc_info.value.code == 401 - - -@pytest.mark.asyncio -async def test_websocket_authentication_not_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection when no auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = True - - # Should be able to connect successfully - async with websocket_connection(dashboard) as ws: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - - -@pytest.mark.asyncio -async def test_websocket_entry_state_changed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry state changed event.""" - # Simulate entry state change - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(True, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - # Should receive state change event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_state_changed" - assert data["data"]["filename"] == entry.filename - assert data["data"]["name"] == entry.name - assert data["data"]["state"] is True - - -@pytest.mark.asyncio -async def test_websocket_entry_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry added event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "test.yaml" - mock_entry.name = "test_device" - mock_entry.to_dict.return_value = { - "name": "test_device", - "filename": "test.yaml", - "configuration": "test.yaml", - } - - # Simulate entry added - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": mock_entry}) - - # Should receive entry added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_added" - assert data["data"]["device"]["name"] == "test_device" - assert data["data"]["device"]["filename"] == "test.yaml" - - -@pytest.mark.asyncio -async def test_websocket_entry_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry removed event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "removed.yaml" - mock_entry.name = "removed_device" - mock_entry.to_dict.return_value = { - "name": "removed_device", - "filename": "removed.yaml", - "configuration": "removed.yaml", - } - - # Simulate entry removed - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": mock_entry}) - - # Should receive entry removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_removed" - assert data["data"]["device"]["name"] == "removed_device" - assert data["data"]["device"]["filename"] == "removed.yaml" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event with real DiscoveredImport.""" - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="new_import_device", - friendly_name="New Import Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "new_import_device" - assert data["data"]["device"]["friendly_name"] == "New Import Device" - assert data["data"]["device"]["project_name"] == "test_project" - assert data["data"]["device"]["network"] == "wifi" - assert data["data"]["device"]["ignored"] is False - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added_ignored( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event for ignored device.""" - # Add device to ignored list - DASHBOARD.ignored_devices.add("ignored_device") - - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event with ignored=True - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "ignored_device" - assert data["data"]["device"]["friendly_name"] == "Ignored Device" - assert data["data"]["device"]["network"] == "ethernet" - assert data["data"]["device"]["ignored"] is True - - -@pytest.mark.asyncio -async def test_websocket_importable_device_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device removed event.""" - # Simulate importable device removed - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - {"name": "removed_import_device"}, - ) - - # Should receive importable device removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_removed" - assert data["data"]["name"] == "removed_import_device" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_already_configured( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test that importable device event is not sent if device is already configured.""" - # Get an existing configured device name - existing_entry = DASHBOARD.entries.async_all()[0] - - # Simulate importable device added with same name as configured device - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - { - "device": { - "name": existing_entry.name, - "friendly_name": "Should Not Be Sent", - "package_import_url": "https://example.com/package", - "project_name": "test_project", - "project_version": "1.0.0", - "network": "wifi", - } - }, - ) - - # Send a ping to ensure connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should only receive pong, not the importable device event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_multiple_connections(dashboard: DashboardTestHelper) -> None: - """Test multiple WebSocket connections.""" - async with ( - websocket_connection(dashboard) as ws1, - websocket_connection(dashboard) as ws2, - ): - # Both should receive initial state - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "initial_state" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "initial_state" - - # Fire an event - both should receive it - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(False, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "entry_state_changed" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "entry_state_changed" - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_lifecycle(dashboard: DashboardTestHelper) -> None: - """Test DashboardSubscriber lifecycle.""" - subscriber = DashboardSubscriber() - - # Initially no subscribers - assert len(subscriber._subscribers) == 0 - assert subscriber._event_loop_task is None - - # Add a subscriber - mock_websocket = Mock() - unsubscribe = subscriber.subscribe(mock_websocket) - - # Should have started the event loop task - assert len(subscriber._subscribers) == 1 - assert subscriber._event_loop_task is not None - - # Unsubscribe - unsubscribe() - - # Should have stopped the task - assert len(subscriber._subscribers) == 0 - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_entries_update_interval( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber entries update interval.""" - # Patch the constants to make the test run faster - with ( - patch("esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 0.01), - patch("esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 2), - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = Mock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait for a few iterations to ensure entries update is called - await asyncio.sleep(0.05) # Should be enough for 2+ iterations - - # Unsubscribe to stop the task - unsubscribe() - - # Verify entries update was called - assert mock_dashboard.entries.async_request_update_entries.call_count >= 1 - # Verify ping request was set multiple times - assert mock_dashboard.ping_request.set.call_count >= 2 - - -@pytest.mark.asyncio -async def test_websocket_refresh_command( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket refresh command triggers dashboard update.""" - with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - # Signal an asyncio.Event when request_refresh is invoked so the - # test can deterministically wait for the server-side handler to run - # instead of relying on a fixed sleep (flaky on Windows CI under load). - called = asyncio.Event() - mock_subscriber.request_refresh = Mock(side_effect=called.set) - - # Send refresh command - await websocket_client.write_message(json.dumps({"event": "refresh"})) - - # Wait for the server to process the message and invoke request_refresh - async with asyncio.timeout(5): - await called.wait() - - # Verify request_refresh was called - mock_subscriber.request_refresh.assert_called_once() - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_refresh_event( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber refresh event triggers immediate update.""" - # Patch the constants to make the test run faster - with ( - patch( - "esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 1.0 - ), # Long timeout - patch( - "esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 100 - ), # Won't reach naturally - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = AsyncMock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait a bit to ensure loop is running - await asyncio.sleep(0.01) - - # Verify entries update hasn't been called yet (iterations not reached) - assert mock_dashboard.entries.async_request_update_entries.call_count == 0 - - # Request refresh - subscriber.request_refresh() - - # Wait for the refresh to be processed - await asyncio.sleep(0.01) - - # Now entries update should have been called - assert mock_dashboard.entries.async_request_update_entries.call_count == 1 - - # Unsubscribe to stop the task - unsubscribe() - - # Give it a moment to clean up - await asyncio.sleep(0.01) - - -@pytest.mark.asyncio -async def test_dashboard_yaml_loading_with_packages_and_secrets( - tmp_path: Path, -) -> None: - """Test dashboard YAML loading with packages referencing secrets. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - This test verifies that CORE.config_path initialization in the dashboard - allows yaml_util.load_yaml() to correctly resolve secrets from packages. - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-download-secrets.yaml" - device_config.write_text( - "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" - "packages:\n common: !include common.yaml\n" - ) - - # Initialize DASHBOARD settings with our test config directory - # This is what sets CORE.config_path - the critical code path for the bug - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - DASHBOARD.settings.parse_args(args) - - # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" - # so CORE.config_path.parent would be config_dir - # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir - # so CORE.config_path.parent would be tmp_path (the parent of config_dir) - - # The fix ensures CORE.config_path.parent points to config_dir - assert CORE.config_path.parent == config_dir.resolve(), ( - f"CORE.config_path.parent should point to config_dir. " - f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " - f"CORE.config_path is {CORE.config_path}" - ) - - # Now load the YAML with packages that reference secrets - # This is where the bug would manifest - yaml_util.load_yaml would fail - # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place - config = yaml_util.load_yaml(device_config) - # If we get here, secret resolution worked! - assert "esphome" in config - assert config["esphome"]["name"] == "test-download-secrets" - - -@pytest.mark.asyncio -async def test_websocket_check_origin_default_same_origin( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" - # Ensure ESPHOME_TRUSTED_DOMAINS is not set - env = os.environ.copy() - env.pop("ESPHOME_TRUSTED_DOMAINS", None) - with patch.dict(os.environ, env, clear=True): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Same origin should work (default Tornado behavior) - request = HTTPRequest( - url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} - ) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_trusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from trusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) - ws = await websocket_connect(request) - try: - # Should receive initial state - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_untrusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket rejects connections from untrusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(request) - # Should get HTTP 403 Forbidden due to origin check failure - assert exc_info.value.code == 403 - - -@pytest.mark.asyncio -async def test_websocket_check_origin_multiple_trusted_domains( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from multiple trusted domains.""" - with patch.dict( - os.environ, - {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, - ): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Test second domain in list (with space after comma) - request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -def test_proc_on_exit_calls_close() -> None: - """Test _proc_on_exit sends exit event and closes the WebSocket.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = False - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_called_once_with({"event": "exit", "code": 0}) - handler.close.assert_called_once() - - -def test_proc_on_exit_skips_when_already_closed() -> None: - """Test _proc_on_exit does nothing when WebSocket is already closed.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = True - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_not_called() - handler.close.assert_not_called() - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_appends_no_states_when_set() -> None: - """Test --no-states is appended when no_states is truthy in the message.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - json_message = { - "configuration": "device.yaml", - "port": "OTA", - "no_states": True, - } - cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) - - assert cmd == [ - "esphome", - "logs", - "device.yaml", - "--device", - "OTA", - "--no-states", - ] - handler.build_device_command.assert_awaited_once_with(["logs"], json_message) - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: - """Test --no-states is not added when no_states is absent from the message.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - cmd = await web_server.EsphomeLogsHandler.build_command( - handler, {"configuration": "device.yaml", "port": "OTA"} - ) - - assert "--no-states" not in cmd - assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_omits_no_states_when_false() -> None: - """Test --no-states is not added when no_states is explicitly False.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - cmd = await web_server.EsphomeLogsHandler.build_command( - handler, - {"configuration": "device.yaml", "port": "OTA", "no_states": False}, - ) - - assert "--no-states" not in cmd - - -def _make_auth_handler(auth_header: str | None = None) -> Mock: - """Create a mock handler with the given Authorization header.""" - handler = Mock() - handler.request = Mock() - if auth_header is not None: - handler.request.headers = {"Authorization": auth_header} - else: - handler.request.headers = {} - handler.get_secure_cookie = Mock(return_value=None) - return handler - - -@pytest.fixture -def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock: - """Fixture to configure mock dashboard settings with auth enabled.""" - mock_dashboard_settings.using_auth = True - mock_dashboard_settings.on_ha_addon = False - return mock_dashboard_settings - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_malformed_base64() -> None: - """Test that invalid base64 in Authorization header returns False.""" - handler = _make_auth_handler("Basic !!!not-valid-base64!!!") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_bad_base64_padding() -> None: - """Test that incorrect base64 padding (binascii.Error) returns False.""" - handler = _make_auth_handler("Basic abc") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_invalid_utf8() -> None: - """Test that base64 decoding to invalid UTF-8 returns False.""" - # \xff\xfe is invalid UTF-8 - bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii") - handler = _make_auth_handler(f"Basic {bad_payload}") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_no_colon() -> None: - """Test that base64 payload without ':' separator returns False.""" - no_colon = base64.b64encode(b"nocolonhere").decode("ascii") - handler = _make_auth_handler(f"Basic {no_colon}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_valid_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth credentials are checked.""" - creds = base64.b64encode(b"admin:secret").decode("ascii") - mock_auth_settings.check_password.return_value = True - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is True - mock_auth_settings.check_password.assert_called_once_with("admin", "secret") - - -def test_is_authenticated_wrong_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth with wrong credentials returns False.""" - creds = base64.b64encode(b"admin:wrong").decode("ascii") - mock_auth_settings.check_password.return_value = False - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_no_auth_configured( - mock_dashboard_settings: MagicMock, -) -> None: - """Test that requests pass when auth is not configured.""" - mock_dashboard_settings.using_auth = False - mock_dashboard_settings.on_ha_addon = False - handler = _make_auth_handler() - assert web_server.is_authenticated(handler) is True diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py deleted file mode 100644 index efeafbf3b5a..00000000000 --- a/tests/dashboard/test_web_server_paths.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Tests for dashboard web_server Path-related functionality.""" - -from __future__ import annotations - -import gzip -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -from esphome.dashboard import web_server - - -def test_get_base_frontend_path_production() -> None: - """Test get_base_frontend_path in production mode.""" - mock_module = MagicMock() - mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard") - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_base_frontend_path() - assert result == Path("/usr/local/lib/esphome_dashboard") - mock_module.where.assert_called_once() - - -def test_get_base_frontend_path_dev_mode() -> None: - """Test get_base_frontend_path in development mode.""" - test_path = "/home/user/esphome/dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None: - """Test get_base_frontend_path in dev mode with trailing slash.""" - test_path = "/home/user/esphome/dashboard/" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - expected = (Path.cwd() / test_path / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_relative_path() -> None: - """Test get_base_frontend_path with relative dev path.""" - test_path = "./dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - assert result.is_absolute() - - -def test_get_static_path_single_component() -> None: - """Test get_static_path with single path component.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("file.js") - - assert result == Path("/base/frontend") / "static" / "file.js" - - -def test_get_static_path_multiple_components() -> None: - """Test get_static_path with multiple path components.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js", "esphome", "index.js") - - assert ( - result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js" - ) - - -def test_get_static_path_empty_args() -> None: - """Test get_static_path with no arguments.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path() - - assert result == Path("/base/frontend") / "static" - - -def test_get_static_path_with_pathlib_path() -> None: - """Test get_static_path with Path objects.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - path_obj = Path("js") / "app.js" - result = web_server.get_static_path(str(path_obj)) - - assert result == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_get_static_file_url_production() -> None: - """Test get_static_file_url in production mode.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_path = MagicMock(spec=Path) - mock_path.read_bytes.return_value = b"test content" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, - ): - mock_get_path.return_value = mock_path - result = web_server.get_static_file_url("js/app.js") - assert result.startswith("./static/js/app.js?hash=") - - -def test_get_static_file_url_dev_mode() -> None: - """Test get_static_file_url in development mode.""" - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}): - web_server.get_static_file_url.cache_clear() - result = web_server.get_static_file_url("js/app.js") - - assert result == "./static/js/app.js" - - -def test_get_static_file_url_index_js_special_case() -> None: - """Test get_static_file_url replaces index.js with entrypoint.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_module.entrypoint.return_value = "main.js" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_static_file_url("js/esphome/index.js") - assert result == "./static/js/esphome/main.js" - - -def test_load_file_path(tmp_path: Path) -> None: - """Test loading a file.""" - test_file = tmp_path / "test.txt" - test_file.write_bytes(b"test content") - - with test_file.open("rb") as f: - content = f.read() - assert content == b"test content" - - -def test_load_file_compressed_path(tmp_path: Path) -> None: - """Test loading a compressed file.""" - test_file = tmp_path / "test.txt.gz" - - with gzip.open(test_file, "wb") as gz: - gz.write(b"compressed content") - - with gzip.open(test_file, "rb") as gz: - content = gz.read() - assert content == b"compressed content" - - -def test_path_normalization_in_static_path() -> None: - """Test that paths are normalized correctly.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - # Test with separate components - result1 = web_server.get_static_path("js", "app.js") - result2 = web_server.get_static_path("js", "app.js") - - assert result1 == result2 - assert result1 == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_windows_path_handling() -> None: - """Test handling of Windows-style paths.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path(r"C:\Program Files\esphome\frontend") - - result = web_server.get_static_path("js", "app.js") - - # Path should handle this correctly on the platform - expected = ( - Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js" - ) - assert result == expected - - -def test_path_with_special_characters() -> None: - """Test paths with special characters.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js-modules", "app_v1.0.js") - - assert ( - result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js" - ) - - -def test_path_with_spaces() -> None: - """Test paths with spaces.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/my frontend") - - result = web_server.get_static_path("my js", "my app.js") - - assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js" diff --git a/tests/dashboard/util/__init__.py b/tests/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index a9876632bd9..d4c13fd3fbd 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -562,7 +562,7 @@ def test_determine_integration_tests( with patch.object( determine_jobs, "changed_files", - return_value=["esphome/dashboard/web_server.py"], + return_value=["esphome/analyze_memory/helpers.py"], ): run_all, test_files = determine_jobs.determine_integration_tests() assert run_all is False @@ -914,7 +914,6 @@ def test_should_run_core_ci_with_branch() -> None: # picks them up because esphome's pyproject sets # include-package-data = true. (["esphome/idf_component.yml"], True), - (["esphome/dashboard/templates/index.html"], True), (["esphome/components/api/api_pb2_service.json"], True), # Mixed: any triggering file is enough (["docs/README.md", "esphome/config.py"], True), diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 70c4b900823..fad249b0bb5 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected): assert helpers.friendly_name_slugify(value) == expected -def test_friendly_name_slugify_back_compat_shim(): - """``esphome.dashboard.util.text`` keeps re-exporting for back-compat. - - The function moved to ``esphome.helpers`` so the new - device-builder dashboard backend can import it without depending - on the legacy dashboard package, but downstream code that still - imports from the old path keeps working until the dashboard - module is removed. - """ - from esphome.dashboard.util.text import ( - friendly_name_slugify as legacy_friendly_name_slugify, - ) - - assert legacy_friendly_name_slugify is helpers.friendly_name_slugify - - @pytest.mark.parametrize( "host", ( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bb06b6c930d..33888956b39 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -33,6 +33,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_dashboard, command_idedata, command_rename, command_run, @@ -3740,6 +3741,45 @@ def test_command_wizard(tmp_path: Path) -> None: mock_wizard.assert_called_once_with(config_file) +def test_command_dashboard_errors_with_device_builder_redirect() -> None: + """The removed dashboard command points users to ESPHome Device Builder.""" + args = MockArgs() + + with pytest.raises(EsphomeError, match="esphome-device-builder"): + command_dashboard(args) + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "dashboard"], + ["esphome", "dashboard", "/config"], + # Legacy flags must be accepted so old invocations reach the redirect + # instead of failing on argparse "unrecognized arguments". + ["esphome", "dashboard", "--port", "6052", "/config"], + ["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"], + [ + "esphome", + "dashboard", + "--address", + "0.0.0.0", + "--socket", + "/x", + "--ha-addon", + ], + ], +) +def test_run_esphome_dashboard_redirects_to_device_builder( + argv: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`esphome dashboard` still parses but fails with the redirect message.""" + result = run_esphome(argv) + + assert result == 1 + assert "esphome-device-builder" in caplog.text + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], From 1d5d5817340617489d2cb97fe32ccd511e80b788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:34:09 -0500 Subject: [PATCH 096/343] [esp8266] Drop stale esphome-docker-base reference (#17123) --- esphome/components/esp8266/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db7120a9ef6..4daf4549ef2 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -131,7 +131,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases From 0d7130c49909d92c1567a2d52690d3842d0982f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 16:48:21 -0500 Subject: [PATCH 097/343] [docs] Remove leftover dashboard references after dashboard removal (#17125) --- AGENTS.md | 2 +- THREAT_MODEL.md | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be2e912d486..21905ea356f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This document provides essential context for AI models interacting with this pro * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. * **Key Libraries/Dependencies:** - * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `tornado` (for the web server), `aioesphomeapi` (for the native API). + * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `aioesphomeapi` (for the native API). * **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server). * **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies). * **Communication Protocols:** Protobuf (for native API), MQTT, HTTP. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index a4640467c98..a4355a50559 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -88,8 +88,6 @@ These *are* security bugs in this repo, and we want to hear about them privately holds the API key / OTA / web credentials). - Anything in the dashboard / device-builder — report that in its own repository (linked at the top). -- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is - deprecated and being replaced by Device Builder; report dashboard issues there. - Deployments where the operator removed protections or exposed credentials. See the security best practices guide: https://esphome.io/guides/security_best_practices/ From 7d7cdb6c66b8692c8b2e2a1111a4df5b71bbfae5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:51:11 +1200 Subject: [PATCH 098/343] Mark configurable classes as final (21/21: zhlt01-zyaura) (#16972) --- esphome/components/zhlt01/zhlt01.h | 2 +- esphome/components/zigbee/automation.h | 2 +- esphome/components/zigbee/time/zigbee_time_zephyr.h | 2 +- esphome/components/zigbee/zigbee_attribute_esp32.h | 2 +- esphome/components/zigbee/zigbee_binary_sensor_zephyr.h | 2 +- esphome/components/zigbee/zigbee_esp32.h | 2 +- esphome/components/zigbee/zigbee_number_zephyr.h | 2 +- esphome/components/zigbee/zigbee_sensor_zephyr.h | 2 +- esphome/components/zigbee/zigbee_switch_zephyr.h | 2 +- esphome/components/zigbee/zigbee_zephyr.h | 2 +- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 2 +- esphome/components/zwave_proxy/zwave_proxy.h | 2 +- esphome/components/zyaura/zyaura.h | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h index 61fc2cc16a1..dba9ca8f3da 100644 --- a/esphome/components/zhlt01/zhlt01.h +++ b/esphome/components/zhlt01/zhlt01.h @@ -142,7 +142,7 @@ static const float AC1_TEMP_MIN = 16.0f; static const float AC1_TEMP_MAX = 32.0f; static const float AC1_TEMP_INC = 1.0f; -class ZHLT01Climate : public climate_ir::ClimateIR { +class ZHLT01Climate final : public climate_ir::ClimateIR { public: ZHLT01Climate() : climate_ir::ClimateIR( diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 55ee9746ea3..1f953100d90 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -9,7 +9,7 @@ #endif namespace esphome::zigbee { -template class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.h b/esphome/components/zigbee/time/zigbee_time_zephyr.h index 3c2adc4b5fa..be2cff786ed 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.h +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.h @@ -12,7 +12,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeTime : public time::RealTimeClock, public ZigbeeEntity { +class ZigbeeTime final : public time::RealTimeClock, public ZigbeeEntity { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 35aa60848f6..e978fcf2097 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -27,7 +27,7 @@ enum ZigbeeReportT { ZIGBEE_REPORT_FORCE, }; -class ZigbeeAttribute : public Component { +class ZigbeeAttribute final : public Component { public: ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t attr_type, float scale, uint8_t max_size) diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h index aae79fa2892..bc2718ff482 100644 --- a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h @@ -28,7 +28,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeBinarySensor : public ZigbeeEntity, public Component { +class ZigbeeBinarySensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor); void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 34b2b827b60..25f53a1d6e4 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -35,7 +35,7 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = f class ZigbeeAttribute; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/zigbee/zigbee_number_zephyr.h b/esphome/components/zigbee/zigbee_number_zephyr.h index aabb0392be8..886e6c32238 100644 --- a/esphome/components/zigbee/zigbee_number_zephyr.h +++ b/esphome/components/zigbee/zigbee_number_zephyr.h @@ -98,7 +98,7 @@ void zb_zcl_analog_output_init_client(); namespace esphome::zigbee { -class ZigbeeNumber : public ZigbeeEntity, public Component { +class ZigbeeNumber final : public ZigbeeEntity, public Component { public: ZigbeeNumber(number::Number *n) : number_(n) {} void set_cluster_attributes(AnalogAttrsOutput &cluster_attributes) { diff --git a/esphome/components/zigbee/zigbee_sensor_zephyr.h b/esphome/components/zigbee/zigbee_sensor_zephyr.h index 37406f21d06..cd03cf8a2b2 100644 --- a/esphome/components/zigbee/zigbee_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_sensor_zephyr.h @@ -69,7 +69,7 @@ void zb_zcl_analog_input_init_client(); namespace esphome::zigbee { -class ZigbeeSensor : public ZigbeeEntity, public Component { +class ZigbeeSensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeSensor(sensor::Sensor *sensor); void set_cluster_attributes(AnalogAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.h b/esphome/components/zigbee/zigbee_switch_zephyr.h index b774c23b3c9..d2f71ce6658 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.h +++ b/esphome/components/zigbee/zigbee_switch_zephyr.h @@ -63,7 +63,7 @@ void zb_zcl_binary_output_init_client(); namespace esphome::zigbee { -class ZigbeeSwitch : public ZigbeeEntity, public Component { +class ZigbeeSwitch final : public ZigbeeEntity, public Component { public: ZigbeeSwitch(switch_::Switch *s) : switch_(s) {} void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index d462d2a4031..3b4a4653616 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -66,7 +66,7 @@ struct AnalogAttrsOutput : AnalogAttrs { float resolution; }; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index d4d2ac974fe..1dbce873073 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -8,7 +8,7 @@ static const char *const TAG = "Zio Ultrasonic"; namespace esphome::zio_ultrasonic { -class ZioUltrasonicComponent : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { +class ZioUltrasonicComponent final : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { public: void dump_config() override; diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index dc5dc46abc0..ec52b15cd96 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -49,7 +49,7 @@ enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; -class ZWaveProxy : public uart::UARTDevice, public Component { +class ZWaveProxy final : public uart::UARTDevice, public Component { public: ZWaveProxy(); diff --git a/esphome/components/zyaura/zyaura.h b/esphome/components/zyaura/zyaura.h index 7c7954dec25..5a451ea4638 100644 --- a/esphome/components/zyaura/zyaura.h +++ b/esphome/components/zyaura/zyaura.h @@ -57,7 +57,7 @@ class ZaSensorStore { }; /// Component for reading temperature/co2/humidity measurements from ZyAura sensors. -class ZyAuraSensor : public PollingComponent { +class ZyAuraSensor final : public PollingComponent { public: void set_pin_clock(InternalGPIOPin *pin) { pin_clock_ = pin; } void set_pin_data(InternalGPIOPin *pin) { pin_data_ = pin; } From 73dbc8214bbad4cdb068b5f8a25744d1a085989d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:51:31 +1200 Subject: [PATCH 099/343] Mark configurable classes as final (4/21: chsc6x-dfplayer) (#16955) --- esphome/components/chsc6x/chsc6x_touchscreen.h | 2 +- esphome/components/climate/automation.h | 6 +++--- .../components/climate_ir_lg/climate_ir_lg.h | 2 +- esphome/components/cm1106/cm1106.h | 4 ++-- .../color_temperature/ct_light_output.h | 2 +- esphome/components/combination/combination.h | 18 +++++++++--------- esphome/components/coolix/coolix.h | 2 +- .../copy/binary_sensor/copy_binary_sensor.h | 2 +- esphome/components/copy/button/copy_button.h | 2 +- esphome/components/copy/cover/copy_cover.h | 2 +- esphome/components/copy/fan/copy_fan.h | 2 +- esphome/components/copy/lock/copy_lock.h | 2 +- esphome/components/copy/number/copy_number.h | 2 +- esphome/components/copy/select/copy_select.h | 2 +- esphome/components/copy/sensor/copy_sensor.h | 2 +- esphome/components/copy/switch/copy_switch.h | 2 +- esphome/components/copy/text/copy_text.h | 2 +- .../copy/text_sensor/copy_text_sensor.h | 2 +- esphome/components/cover/automation.h | 18 +++++++++--------- esphome/components/cs5460a/cs5460a.h | 8 ++++---- esphome/components/cse7761/cse7761.h | 2 +- esphome/components/cse7766/cse7766.h | 2 +- .../cst226/binary_sensor/cs226_button.h | 8 ++++---- .../cst226/touchscreen/cst226_touchscreen.h | 2 +- .../cst816/touchscreen/cst816_touchscreen.h | 2 +- esphome/components/ct_clamp/ct_clamp_sensor.h | 2 +- .../current_based/current_based_cover.h | 2 +- esphome/components/cwww/cwww_light_output.h | 2 +- esphome/components/dac7678/dac7678_output.h | 4 ++-- esphome/components/daikin/daikin.h | 2 +- esphome/components/daikin_arc/daikin_arc.h | 2 +- esphome/components/daikin_brc/daikin_brc.h | 2 +- esphome/components/dallas_temp/dallas_temp.h | 2 +- esphome/components/daly_bms/daly_bms.h | 2 +- esphome/components/datetime/date_entity.h | 2 +- esphome/components/datetime/datetime_base.h | 2 +- esphome/components/datetime/datetime_entity.h | 4 ++-- esphome/components/datetime/time_entity.h | 4 ++-- esphome/components/debug/debug_component.h | 2 +- .../deep_sleep/deep_sleep_component.h | 9 +++++---- esphome/components/delonghi/delonghi.h | 2 +- .../components/demo/demo_alarm_control_panel.h | 2 +- esphome/components/demo/demo_binary_sensor.h | 2 +- esphome/components/demo/demo_button.h | 2 +- esphome/components/demo/demo_climate.h | 2 +- esphome/components/demo/demo_cover.h | 2 +- esphome/components/demo/demo_date.h | 2 +- esphome/components/demo/demo_datetime.h | 2 +- esphome/components/demo/demo_fan.h | 2 +- esphome/components/demo/demo_light.h | 2 +- esphome/components/demo/demo_lock.h | 2 +- esphome/components/demo/demo_number.h | 2 +- esphome/components/demo/demo_select.h | 2 +- esphome/components/demo/demo_sensor.h | 2 +- esphome/components/demo/demo_switch.h | 2 +- esphome/components/demo/demo_text.h | 2 +- esphome/components/demo/demo_text_sensor.h | 2 +- esphome/components/demo/demo_time.h | 2 +- esphome/components/demo/demo_valve.h | 2 +- esphome/components/dew_point/dew_point.h | 2 +- esphome/components/dfplayer/dfplayer.h | 16 ++++++++-------- 61 files changed, 100 insertions(+), 99 deletions(-) diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 32077b3d33c..84e539e5f25 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -17,7 +17,7 @@ static const uint8_t CHSC6X_REG_STATUS_Y_COR = 0x04; static const uint8_t CHSC6X_REG_STATUS_LEN = 0x05; static const uint8_t CHSC6X_CHIP_ID = 0x2e; -class CHSC6XTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CHSC6XTouchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 6ac9bd8bae4..a8d6d778ae4 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -17,7 +17,7 @@ namespace esphome::climate { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} @@ -33,14 +33,14 @@ template class ControlAction : public Action { ApplyFn apply_; }; -class ControlTrigger : public Trigger { +class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); } }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: StateTrigger(Climate *climate) { climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.h b/esphome/components/climate_ir_lg/climate_ir_lg.h index a09da65ac6c..341f0a4ef1f 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -10,7 +10,7 @@ namespace esphome::climate_ir_lg { const uint8_t TEMP_MIN = 18; // Celsius const uint8_t TEMP_MAX = 30; // Celsius -class LgIrClimate : public climate_ir::ClimateIR { +class LgIrClimate final : public climate_ir::ClimateIR { public: LgIrClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 047e91d6326..844bfdfa880 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -7,7 +7,7 @@ namespace esphome::cm1106 { -class CM1106Component : public PollingComponent, public uart::UARTDevice { +class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -23,7 +23,7 @@ class CM1106Component : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction : public Action { +template class CM1106CalibrateZeroAction final : public Action { public: CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} diff --git a/esphome/components/color_temperature/ct_light_output.h b/esphome/components/color_temperature/ct_light_output.h index a4da1011b09..51ca21465e9 100644 --- a/esphome/components/color_temperature/ct_light_output.h +++ b/esphome/components/color_temperature/ct_light_output.h @@ -6,7 +6,7 @@ namespace esphome::color_temperature { -class CTLightOutput : public light::LightOutput { +class CTLightOutput final : public light::LightOutput { public: void set_color_temperature(output::FloatOutput *color_temperature) { color_temperature_ = color_temperature; } void set_brightness(output::FloatOutput *brightness) { brightness_ = brightness; } diff --git a/esphome/components/combination/combination.h b/esphome/components/combination/combination.h index 34e9e4e2c65..00745663e08 100644 --- a/esphome/components/combination/combination.h +++ b/esphome/components/combination/combination.h @@ -58,7 +58,7 @@ class CombinationOneParameterComponent : public CombinationComponent { FixedVector sensor_sources_; }; -class KalmanCombinationComponent : public CombinationOneParameterComponent { +class KalmanCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override; void setup() override; @@ -85,7 +85,7 @@ class KalmanCombinationComponent : public CombinationOneParameterComponent { float variance_{INFINITY}; }; -class LinearCombinationComponent : public CombinationOneParameterComponent { +class LinearCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("linear")); } void setup() override; @@ -93,49 +93,49 @@ class LinearCombinationComponent : public CombinationOneParameterComponent { void handle_new_value(float value); }; -class MaximumCombinationComponent : public CombinationNoParameterComponent { +class MaximumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("max")); } void handle_new_value(float value) override; }; -class MeanCombinationComponent : public CombinationNoParameterComponent { +class MeanCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("mean")); } void handle_new_value(float value) override; }; -class MedianCombinationComponent : public CombinationNoParameterComponent { +class MedianCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("median")); } void handle_new_value(float value) override; }; -class MinimumCombinationComponent : public CombinationNoParameterComponent { +class MinimumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("min")); } void handle_new_value(float value) override; }; -class MostRecentCombinationComponent : public CombinationNoParameterComponent { +class MostRecentCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("most_recently_updated")); } void handle_new_value(float value) override; }; -class RangeCombinationComponent : public CombinationNoParameterComponent { +class RangeCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("range")); } void handle_new_value(float value) override; }; -class SumCombinationComponent : public CombinationNoParameterComponent { +class SumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("sum")); } diff --git a/esphome/components/coolix/coolix.h b/esphome/components/coolix/coolix.h index 2d8862e2b63..6a59a58a921 100644 --- a/esphome/components/coolix/coolix.h +++ b/esphome/components/coolix/coolix.h @@ -10,7 +10,7 @@ namespace esphome::coolix { const uint8_t COOLIX_TEMP_MIN = 17; // Celsius const uint8_t COOLIX_TEMP_MAX = 30; // Celsius -class CoolixClimate : public climate_ir::ClimateIR { +class CoolixClimate final : public climate_ir::ClimateIR { public: CoolixClimate() : climate_ir::ClimateIR(COOLIX_TEMP_MIN, COOLIX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.h b/esphome/components/copy/binary_sensor/copy_binary_sensor.h index a6ce705a2a4..b30ca9cb212 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.h +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyBinarySensor : public binary_sensor::BinarySensor, public Component { +class CopyBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(binary_sensor::BinarySensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/button/copy_button.h b/esphome/components/copy/button/copy_button.h index afd783375d9..bdefcc512a6 100644 --- a/esphome/components/copy/button/copy_button.h +++ b/esphome/components/copy/button/copy_button.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyButton : public button::Button, public Component { +class CopyButton final : public button::Button, public Component { public: void set_source(button::Button *source) { source_ = source; } void dump_config() override; diff --git a/esphome/components/copy/cover/copy_cover.h b/esphome/components/copy/cover/copy_cover.h index 0b493e4c3bb..008cbdf28e1 100644 --- a/esphome/components/copy/cover/copy_cover.h +++ b/esphome/components/copy/cover/copy_cover.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyCover : public cover::Cover, public Component { +class CopyCover final : public cover::Cover, public Component { public: void set_source(cover::Cover *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/fan/copy_fan.h b/esphome/components/copy/fan/copy_fan.h index 9090c910957..4f882ba43d3 100644 --- a/esphome/components/copy/fan/copy_fan.h +++ b/esphome/components/copy/fan/copy_fan.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyFan : public fan::Fan, public Component { +class CopyFan final : public fan::Fan, public Component { public: void set_source(fan::Fan *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/lock/copy_lock.h b/esphome/components/copy/lock/copy_lock.h index c6c46467a9e..0177db1708c 100644 --- a/esphome/components/copy/lock/copy_lock.h +++ b/esphome/components/copy/lock/copy_lock.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyLock : public lock::Lock, public Component { +class CopyLock final : public lock::Lock, public Component { public: void set_source(lock::Lock *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/number/copy_number.h b/esphome/components/copy/number/copy_number.h index b4d8bb83e68..82af6cc8ea2 100644 --- a/esphome/components/copy/number/copy_number.h +++ b/esphome/components/copy/number/copy_number.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyNumber : public number::Number, public Component { +class CopyNumber final : public number::Number, public Component { public: void set_source(number::Number *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index 1a17c7a55aa..2c541d99bc1 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySelect : public select::Select, public Component { +class CopySelect final : public select::Select, public Component { public: void set_source(select::Select *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/sensor/copy_sensor.h b/esphome/components/copy/sensor/copy_sensor.h index d6e5026ce13..136c36de958 100644 --- a/esphome/components/copy/sensor/copy_sensor.h +++ b/esphome/components/copy/sensor/copy_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySensor : public sensor::Sensor, public Component { +class CopySensor final : public sensor::Sensor, public Component { public: void set_source(sensor::Sensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/switch/copy_switch.h b/esphome/components/copy/switch/copy_switch.h index 9ce6b48ed14..bef254093ce 100644 --- a/esphome/components/copy/switch/copy_switch.h +++ b/esphome/components/copy/switch/copy_switch.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySwitch : public switch_::Switch, public Component { +class CopySwitch final : public switch_::Switch, public Component { public: void set_source(switch_::Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text/copy_text.h b/esphome/components/copy/text/copy_text.h index ad289365227..4dd3b5fe5ed 100644 --- a/esphome/components/copy/text/copy_text.h +++ b/esphome/components/copy/text/copy_text.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyText : public text::Text, public Component { +class CopyText final : public text::Text, public Component { public: void set_source(text::Text *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.h b/esphome/components/copy/text_sensor/copy_text_sensor.h index dc4ef7a29da..e27e3bc1d76 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.h +++ b/esphome/components/copy/text_sensor/copy_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyTextSensor : public text_sensor::TextSensor, public Component { +class CopyTextSensor final : public text_sensor::TextSensor, public Component { public: void set_source(text_sensor::TextSensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index ee7a4f5f760..0a5a447ab92 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,7 +6,7 @@ namespace esphome::cover { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Cover *cover) : cover_(cover) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Cover *cover_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Cover *cover) : cover_(cover) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Cover *cover_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Cover *cover) : cover_(cover) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Cover *cover_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Cover *cover) : cover_(cover) {} @@ -59,7 +59,7 @@ template class ToggleAction : public Action { // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -75,7 +75,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class CoverPublishAction : public Action { +template class CoverPublishAction final : public Action { public: using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -90,7 +90,7 @@ template class CoverPublishAction : public Action { ApplyFn apply_; }; -template class CoverPositionCondition : public Condition { +template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} @@ -103,7 +103,7 @@ template class CoverPositionCondition : public Condit template using CoverIsOpenCondition = CoverPositionCondition; template using CoverIsClosedCondition = CoverPositionCondition; -template class CoverPositionTrigger : public Trigger<> { +template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { @@ -123,7 +123,7 @@ template class CoverPositionTrigger : public Trigger<> { using CoverOpenedTrigger = CoverPositionTrigger; using CoverClosedTrigger = CoverPositionTrigger; -template class CoverTrigger : public Trigger<> { +template class CoverTrigger final : public Trigger<> { public: CoverTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index c6b02f53ee7..87ea858c70c 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -52,9 +52,9 @@ enum CS5460APGAGain { CS5460A_PGA_GAIN_50X = 0b1, }; -class CS5460AComponent : public Component, - public spi::SPIDevice { +class CS5460AComponent final : public Component, + public spi::SPIDevice { public: void set_samples(uint32_t samples) { samples_ = samples; } void set_phase_offset(int8_t phase_offset) { phase_offset_ = phase_offset; } @@ -108,7 +108,7 @@ class CS5460AComponent : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction : public Action { +template class CS5460ARestartAction final : public Action { public: CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 5f683f424bd..e08ebf09cc9 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -16,7 +16,7 @@ struct CSE7761DataStruct { }; /// This class implements support for the CSE7761 UART power sensor. -class CSE7761Component : public PollingComponent, public uart::UARTDevice { +class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 77b80dd8244..8a57816a590 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -9,7 +9,7 @@ namespace esphome::cse7766 { static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; -class CSE7766Component : public Component, public uart::UARTDevice { +class CSE7766Component final : public Component, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/cst226/binary_sensor/cs226_button.h b/esphome/components/cst226/binary_sensor/cs226_button.h index e7e334b9bbf..ec07341e219 100644 --- a/esphome/components/cst226/binary_sensor/cs226_button.h +++ b/esphome/components/cst226/binary_sensor/cs226_button.h @@ -6,10 +6,10 @@ namespace esphome::cst226 { -class CST226Button : public binary_sensor::BinarySensor, - public Component, - public CST226ButtonListener, - public Parented { +class CST226Button final : public binary_sensor::BinarySensor, + public Component, + public CST226ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.h b/esphome/components/cst226/touchscreen/cst226_touchscreen.h index 362eee5fc2d..c68c50fb448 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.h +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.h @@ -15,7 +15,7 @@ class CST226ButtonListener { virtual void update_button(bool state) = 0; }; -class CST226Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST226Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 19c169c3ecb..84b561c7345 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -37,7 +37,7 @@ class CST816ButtonListener { virtual void update_button(bool state) = 0; }; -class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST816Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.h b/esphome/components/ct_clamp/ct_clamp_sensor.h index 2055edbd3eb..ae77c04390d 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.h +++ b/esphome/components/ct_clamp/ct_clamp_sensor.h @@ -7,7 +7,7 @@ namespace esphome::ct_clamp { -class CTClampSensor : public sensor::Sensor, public PollingComponent { +class CTClampSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/current_based/current_based_cover.h b/esphome/components/current_based/current_based_cover.h index 531f8d5a4f5..41dd9962b70 100644 --- a/esphome/components/current_based/current_based_cover.h +++ b/esphome/components/current_based/current_based_cover.h @@ -8,7 +8,7 @@ namespace esphome::current_based { -class CurrentBasedCover : public cover::Cover, public Component { +class CurrentBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/cwww/cwww_light_output.h b/esphome/components/cwww/cwww_light_output.h index 6eed8de7ccc..aea844008fb 100644 --- a/esphome/components/cwww/cwww_light_output.h +++ b/esphome/components/cwww/cwww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::cwww { -class CWWWLightOutput : public light::LightOutput { +class CWWWLightOutput final : public light::LightOutput { public: void set_cold_white(output::FloatOutput *cold_white) { cold_white_ = cold_white; } void set_warm_white(output::FloatOutput *warm_white) { warm_white_ = warm_white; } diff --git a/esphome/components/dac7678/dac7678_output.h b/esphome/components/dac7678/dac7678_output.h index a0173259398..00021e947f8 100644 --- a/esphome/components/dac7678/dac7678_output.h +++ b/esphome/components/dac7678/dac7678_output.h @@ -9,7 +9,7 @@ namespace esphome::dac7678 { class DAC7678Output; -class DAC7678Channel : public output::FloatOutput, public Parented { +class DAC7678Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -24,7 +24,7 @@ class DAC7678Channel : public output::FloatOutput, public Parented day_; }; -template class DateSetAction : public Action, public Parented { +template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index 6c0a33c8420..f99debb692d 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,7 +31,7 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger : public Trigger { +class DateTimeStateTrigger final : public Trigger { public: explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index b1b8a77846a..159e4ccc6f5 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,7 +121,7 @@ class DateTimeCall { optional second_; }; -template class DateTimeSetAction : public Action, public Parented { +template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) @@ -136,7 +136,7 @@ template class DateTimeSetAction : public Action, public }; #ifdef USE_TIME -class OnDateTimeTrigger : public Trigger<>, public Component, public Parented { +class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 3f224684bbd..643f4bd176a 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,7 +98,7 @@ class TimeCall { optional second_; }; -template class TimeSetAction : public Action, public Parented { +template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) @@ -113,7 +113,7 @@ template class TimeSetAction : public Action, public Pare }; #ifdef USE_TIME -class OnTimeTrigger : public Trigger<>, public Component, public Parented { +class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 871b7cfd258..20798cf6006 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -21,7 +21,7 @@ static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h -class DebugComponent : public PollingComponent { +class DebugComponent final : public PollingComponent { public: void loop() override; void update() override; diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 2df53f15407..8edda040d3a 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -70,7 +70,7 @@ template class PreventDeepSleepAction; * and set_run_duration, then set how long the deep sleep should last using set_sleep_duration and optionally * on the ESP32 set_wakeup_pin. */ -class DeepSleepComponent : public Component { +class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. void set_sleep_duration(uint32_t time_ms); @@ -161,7 +161,7 @@ class DeepSleepComponent : public Component { extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class EnterDeepSleepAction : public Action { +template class EnterDeepSleepAction final : public Action { public: EnterDeepSleepAction(DeepSleepComponent *deep_sleep) : deep_sleep_(deep_sleep) {} TEMPLATABLE_VALUE(uint32_t, sleep_duration); @@ -233,12 +233,13 @@ template class EnterDeepSleepAction : public Action { #endif }; -template class PreventDeepSleepAction : public Action, public Parented { +template +class PreventDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } }; -template class AllowDeepSleepAction : public Action, public Parented { +template class AllowDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; diff --git a/esphome/components/delonghi/delonghi.h b/esphome/components/delonghi/delonghi.h index c2fbc36b4f7..aee7ceeddaa 100644 --- a/esphome/components/delonghi/delonghi.h +++ b/esphome/components/delonghi/delonghi.h @@ -39,7 +39,7 @@ const uint32_t DELONGHI_ZERO_SPACE = 670; // State Frame size const uint8_t DELONGHI_STATE_FRAME_SIZE = 8; -class DelonghiClimate : public climate_ir::ClimateIR { +class DelonghiClimate final : public climate_ir::ClimateIR { public: DelonghiClimate() : climate_ir::ClimateIR(DELONGHI_TEMP_MIN, DELONGHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 7aaf3219cf1..e85d2a17ba6 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -13,7 +13,7 @@ enum class DemoAlarmControlPanelType { TYPE_3, }; -class DemoAlarmControlPanel : public AlarmControlPanel, public Component { +class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { public: void setup() override {} diff --git a/esphome/components/demo/demo_binary_sensor.h b/esphome/components/demo/demo_binary_sensor.h index 4bc3737d5a2..6a98a6781b6 100644 --- a/esphome/components/demo/demo_binary_sensor.h +++ b/esphome/components/demo/demo_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class DemoBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void setup() override { this->publish_initial_state(false); } void update() override { diff --git a/esphome/components/demo/demo_button.h b/esphome/components/demo/demo_button.h index a0ed92d3d8e..907136cfc6c 100644 --- a/esphome/components/demo/demo_button.h +++ b/esphome/components/demo/demo_button.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoButton : public button::Button { +class DemoButton final : public button::Button { protected: void press_action() override {} }; diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index d0cd2d553d3..20affb909f5 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -11,7 +11,7 @@ enum class DemoClimateType { TYPE_3, }; -class DemoClimate : public climate::Climate, public Component { +class DemoClimate final : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index c1597a75653..aa12c885f43 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -12,7 +12,7 @@ enum class DemoCoverType { TYPE_4, }; -class DemoCover : public cover::Cover, public Component { +class DemoCover final : public cover::Cover, public Component { public: void set_type(DemoCoverType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_date.h b/esphome/components/demo/demo_date.h index 5a868342cd4..f724c82435c 100644 --- a/esphome/components/demo/demo_date.h +++ b/esphome/components/demo/demo_date.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDate : public datetime::DateEntity, public Component { +class DemoDate final : public datetime::DateEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_datetime.h b/esphome/components/demo/demo_datetime.h index 84869d1a9f8..363592c5542 100644 --- a/esphome/components/demo/demo_datetime.h +++ b/esphome/components/demo/demo_datetime.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDateTime : public datetime::DateTimeEntity, public Component { +class DemoDateTime final : public datetime::DateTimeEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_fan.h b/esphome/components/demo/demo_fan.h index 2e2fbce7d66..be44c06ca28 100644 --- a/esphome/components/demo/demo_fan.h +++ b/esphome/components/demo/demo_fan.h @@ -12,7 +12,7 @@ enum class DemoFanType { TYPE_4, }; -class DemoFan : public fan::Fan, public Component { +class DemoFan final : public fan::Fan, public Component { public: void set_type(DemoFanType type) { type_ = type; } fan::FanTraits get_traits() override { diff --git a/esphome/components/demo/demo_light.h b/esphome/components/demo/demo_light.h index 071adb0831c..4a48a1796e6 100644 --- a/esphome/components/demo/demo_light.h +++ b/esphome/components/demo/demo_light.h @@ -22,7 +22,7 @@ enum class DemoLightType { TYPE_7, }; -class DemoLight : public light::LightOutput, public Component { +class DemoLight final : public light::LightOutput, public Component { public: void set_type(DemoLightType type) { type_ = type; } light::LightTraits get_traits() override { diff --git a/esphome/components/demo/demo_lock.h b/esphome/components/demo/demo_lock.h index 85c1c238ef7..473fe1a68ea 100644 --- a/esphome/components/demo/demo_lock.h +++ b/esphome/components/demo/demo_lock.h @@ -4,7 +4,7 @@ namespace esphome::demo { -class DemoLock : public lock::Lock { +class DemoLock final : public lock::Lock { protected: void control(const lock::LockCall &call) override { auto state = call.get_state(); diff --git a/esphome/components/demo/demo_number.h b/esphome/components/demo/demo_number.h index 0059cdc2eec..f66aef1aff4 100644 --- a/esphome/components/demo/demo_number.h +++ b/esphome/components/demo/demo_number.h @@ -11,7 +11,7 @@ enum class DemoNumberType { TYPE_3, }; -class DemoNumber : public number::Number, public Component { +class DemoNumber final : public number::Number, public Component { public: void set_type(DemoNumberType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_select.h b/esphome/components/demo/demo_select.h index 2ecb37db99f..de57f2f0247 100644 --- a/esphome/components/demo/demo_select.h +++ b/esphome/components/demo/demo_select.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoSelect : public select::Select, public Component { +class DemoSelect final : public select::Select, public Component { protected: void control(size_t index) override { this->publish_state(index); } }; diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 867115f21bc..6153c810e1c 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSensor : public sensor::Sensor, public PollingComponent { +class DemoSensor final : public sensor::Sensor, public PollingComponent { public: void update() override { float val = random_float(); diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index b2d6e52c673..6846b8b663c 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSwitch : public switch_::Switch, public Component { +class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { bool initial = random_float() < 0.5; diff --git a/esphome/components/demo/demo_text.h b/esphome/components/demo/demo_text.h index 56376c8c42a..66dd5bc3ebf 100644 --- a/esphome/components/demo/demo_text.h +++ b/esphome/components/demo/demo_text.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoText : public text::Text, public Component { +class DemoText final : public text::Text, public Component { public: void setup() override { this->publish_state("I am a text entity"); } diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index 03852a1e7f8..fa728903d9e 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoTextSensor : public text_sensor::TextSensor, public PollingComponent { +class DemoTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void update() override { float val = random_float(); diff --git a/esphome/components/demo/demo_time.h b/esphome/components/demo/demo_time.h index f94678fae40..90384b3216f 100644 --- a/esphome/components/demo/demo_time.h +++ b/esphome/components/demo/demo_time.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoTime : public datetime::TimeEntity, public Component { +class DemoTime final : public datetime::TimeEntity, public Component { public: void setup() override { this->hour_ = 3; diff --git a/esphome/components/demo/demo_valve.h b/esphome/components/demo/demo_valve.h index 3f1342959ad..22183b75e8f 100644 --- a/esphome/components/demo/demo_valve.h +++ b/esphome/components/demo/demo_valve.h @@ -9,7 +9,7 @@ enum class DemoValveType { TYPE_2, }; -class DemoValve : public valve::Valve { +class DemoValve final : public valve::Valve { public: valve::ValveTraits get_traits() override { valve::ValveTraits traits; diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h index 833c50fba25..0e97b22a04e 100644 --- a/esphome/components/dew_point/dew_point.h +++ b/esphome/components/dew_point/dew_point.h @@ -5,7 +5,7 @@ namespace esphome::dew_point { -class DewPointComponent : public Component, public sensor::Sensor { +class DewPointComponent final : public Component, public sensor::Sensor { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 5936a06b60c..1db6b394c58 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -24,7 +24,7 @@ enum Device { // See the datasheet here: // https://github.com/DFRobot/DFRobotDFPlayerMini/blob/master/doc/FN-M16P%2BEmbedded%2BMP3%2BAudio%2BModule%2BDatasheet.pdf -class DFPlayer : public uart::UARTDevice, public Component { +class DFPlayer final : public uart::UARTDevice, public Component { public: void loop() override; @@ -82,7 +82,7 @@ class DFPlayer : public uart::UARTDevice, public Component { DFPLAYER_SIMPLE_ACTION(NextAction, next) DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) -template class PlayMp3Action : public Action, public Parented { +template class PlayMp3Action final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) @@ -92,7 +92,7 @@ template class PlayMp3Action : public Action, public Pare } }; -template class PlayFileAction : public Action, public Parented { +template class PlayFileAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) TEMPLATABLE_VALUE(bool, loop) @@ -108,7 +108,7 @@ template class PlayFileAction : public Action, public Par } }; -template class PlayFolderAction : public Action, public Parented { +template class PlayFolderAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, folder) TEMPLATABLE_VALUE(uint16_t, file) @@ -126,7 +126,7 @@ template class PlayFolderAction : public Action, public P } }; -template class SetDeviceAction : public Action, public Parented { +template class SetDeviceAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Device, device) @@ -136,7 +136,7 @@ template class SetDeviceAction : public Action, public Pa } }; -template class SetVolumeAction : public Action, public Parented { +template class SetVolumeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, volume) @@ -146,7 +146,7 @@ template class SetVolumeAction : public Action, public Pa } }; -template class SetEqAction : public Action, public Parented { +template class SetEqAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(EqPreset, eq) @@ -165,7 +165,7 @@ DFPLAYER_SIMPLE_ACTION(RandomAction, random) DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) -template class DFPlayerIsPlayingCondition : public Condition, public Parented { +template class DFPlayerIsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; From 77a91853beb6b448174a70cb210b20a09741c690 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 21 Jun 2026 17:52:05 -0400 Subject: [PATCH 100/343] [i2s_audio] Narrow wider streams to the speaker's configured bit depth (#16821) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/i2s_audio/speaker/__init__.py | 15 ++- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 2 + .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../i2s_audio/speaker/i2s_audio_speaker.h | 9 +- .../speaker/i2s_audio_speaker_standard.cpp | 116 ++++++++++++------ 5 files changed, 99 insertions(+), 45 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 5ba2f4b1a51..6d3c39c68eb 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -104,23 +104,26 @@ def _set_stream_limits(config): # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + # The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it + # in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams + # are accepted rather than forcing an upstream conversion. + max_bits_per_sample = 32 + if config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the - # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the - # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. + # Primary mode can reconfigure the bus to the incoming sample rate and channel count. audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=16000, max_sample_rate=48000, )(config) else: - # Secondary mode has unmodifiable max bits per sample and min/max sample rates + # Secondary mode has unmodifiable min/max sample rates audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 989bcf29770..ed5145d4b0e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -404,6 +404,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { this->current_stream_info_ = audio_stream_info; + // SPDIF never narrows the bit depth; the encoder consumes the input format directly. + this->output_stream_info_ = audio_stream_info; // SPDIF mode validation if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 691f68e912f..c6ff42495f6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -354,7 +354,7 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) { + if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 34792bdbeae..adb6ca5e3f7 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -134,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void apply_software_volume_(uint8_t *data, size_t bytes_read); /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono audio. + /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is + /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); @@ -156,7 +157,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public int32_t q31_volume_factor_{INT32_MAX}; - audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info + audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) + // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as + // current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than + // the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts. + audio::AudioStreamInfo output_stream_info_; gpio_num_t dout_pin_; i2s_chan_handle_t tx_handle_{nullptr}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 0afb67fb368..17c93763d63 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -13,6 +13,9 @@ #include "esp_timer.h" +// esp-audio-libs +#include + namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; @@ -62,6 +65,12 @@ void I2SAudioSpeaker::dump_config() { break; } ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + // The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to + // it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this + // is not necessarily the sample data width (which depends on the incoming stream). + ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast(this->slot_bit_width_)); + } } void I2SAudioSpeaker::run_speaker_task() { @@ -71,12 +80,19 @@ void I2SAudioSpeaker::run_speaker_task() { // Ensure ring buffer duration is at least the duration of all DMA buffers const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + + // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. + const uint8_t channels = this->current_stream_info_.get_channels(); + const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8; + const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8; + const bool narrowing = input_bytes_per_sample != output_bytes_per_sample; + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually @@ -89,9 +105,12 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); } - const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); + // dma_buffer_bytes counts output-format bytes; convert with the output stream info. + const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); + // Soft cap for each source read: enough input-format bytes to fill one DMA buffer's worth of frames. + const size_t dma_buffer_input_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); bool successful_setup = false; @@ -105,8 +124,8 @@ void I2SAudioSpeaker::run_speaker_task() { memset(silence_buffer, 0, dma_buffer_bytes); std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - audio_source = - audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast(bytes_per_frame)); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, + static_cast(bytes_per_frame)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -237,42 +256,61 @@ void I2SAudioSpeaker::run_speaker_task() { // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any // remainder with silence. All writes pack into the next free DMA descriptor in order, so the - // descriptor ends up holding [real audio][silence padding]. + // descriptor ends up holding [real audio][silence padding]. ``bytes_written_total`` counts + // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. size_t bytes_written_total = 0; - size_t real_bytes_total = 0; + uint32_t real_frames_total = 0; bool partial_write_failure = false; if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); if (bytes_read > 0) { + // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; this->apply_software_volume_(new_data, bytes_read); - this->swap_esp32_mono_samples_(new_data, bytes_read); } - const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total); - if (to_write == 0) { + // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source + // currently exposes. Frame counts are shared between input and output; only the byte widths differ. + const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + const uint32_t frames_room = + this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + const uint32_t frames_to_write = std::min(frames_available, frames_room); + if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer // with silence so the lockstep invariant (one write per iteration) is preserved. break; } + const size_t input_bytes = this->current_stream_info_.frames_to_bytes(frames_to_write); + const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write); + + uint8_t *chunk = audio_source->mutable_data(); + if (narrowing) { + // Narrow the bit depth in place: output exactly aliases input with the same channel count and a + // smaller width, which copy_frames handles as a single forward pass. Only the frames about to be + // consumed are overwritten, so any unprocessed tail stays intact for the next iteration. + esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels, + output_bytes_per_sample, channels, frames_to_write); + } + this->swap_esp32_mono_samples_(chunk, output_bytes); + size_t bw = 0; - i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS); - if (bw != to_write) { + i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != output_bytes) { // A short real-audio write breaks DMA descriptor alignment for every subsequent event; // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write); + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); partial_write_failure = true; break; } - audio_source->consume(bw); - bytes_written_total += bw; - real_bytes_total += bw; + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; } - if (real_bytes_total > 0) { + if (real_frames_total > 0) { last_data_received_time = millis(); } } @@ -293,16 +331,15 @@ void I2SAudioSpeaker::run_speaker_task() { } } - const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total); // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep // invariant is broken and every subsequent timestamp would be silently wrong, so bail. - if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) { + if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { ESP_LOGV(TAG, "Exiting: write records queue full"); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); break; } - if (real_frames_in_buffer > 0) { + if (real_frames_total > 0) { pending_real_buffers++; } } @@ -334,21 +371,28 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - // Currently can't handle the case when the incoming audio has more bits per sample than the configured value - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; + // When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place + // before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and + // the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed + // through unchanged (the slot may still be wider than the data, the existing behavior). + uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + const uint8_t configured_bits = static_cast(this->slot_bit_width_); + if (output_bits_per_sample > configured_bits) { + output_bits_per_sample = configured_bits; + } } + this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(), + audio_stream_info.get_sample_rate()); #ifdef USE_ESP32_VARIANT_ESP32 // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple - // of 16, so reject anything else rather than emit corrupted audio. - if (audio_stream_info.get_bits_per_sample() % 16 != 0) { - ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", - (unsigned) audio_stream_info.get_bits_per_sample()); + // of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine + // as long as it narrows to a 16- or 32-bit slot. + if (output_bits_per_sample % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } #endif // USE_ESP32_VARIANT_ESP32 @@ -358,7 +402,8 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; @@ -398,19 +443,18 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream slot_mask = I2S_STD_SLOT_BOTH; } + // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); i2s_std_slot_config_t slot_cfg; switch (this->i2s_comm_fmt_) { case I2SCommFmt::PCM: - slot_cfg = - I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; case I2SCommFmt::MSB: - slot_cfg = - I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; default: - slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), - slot_mode); + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; } From cce7cfff29ca6702910d9c5cea49f8aea4e82624 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:52:32 +1200 Subject: [PATCH 101/343] Mark configurable classes as final (3/21: ble_scanner-ch423) (#16954) --- esphome/components/b_parasite/b_parasite.h | 2 +- esphome/components/ble_scanner/ble_scanner.h | 4 +- esphome/components/bm8563/bm8563.h | 8 ++-- esphome/components/bme280_i2c/bme280_i2c.h | 2 +- esphome/components/bme280_spi/bme280_spi.h | 6 +-- esphome/components/bme680/bme680.h | 2 +- esphome/components/bme680_bsec/bme680_bsec.h | 2 +- .../bme68x_bsec2_i2c/bme68x_bsec2_i2c.h | 2 +- esphome/components/bmi160/bmi160.h | 2 +- esphome/components/bmi270/bmi270.h | 2 +- esphome/components/bmp085/bmp085.h | 2 +- esphome/components/bmp280_i2c/bmp280_i2c.h | 2 +- esphome/components/bmp280_spi/bmp280_spi.h | 6 +-- esphome/components/bmp3xx_i2c/bmp3xx_i2c.h | 2 +- esphome/components/bmp3xx_spi/bmp3xx_spi.h | 6 +-- esphome/components/bmp581_i2c/bmp581_i2c.h | 2 +- esphome/components/bmp581_spi/bmp581_spi.h | 6 +-- esphome/components/bp1658cj/bp1658cj.h | 4 +- esphome/components/bp5758d/bp5758d.h | 4 +- .../bthome_mithermometer/bthome_ble.h | 2 +- esphome/components/button/automation.h | 4 +- .../camera_encoder/encoder_buffer_impl.h | 2 +- .../esp32_camera_jpeg_encoder.h | 2 +- esphome/components/canbus/canbus.h | 4 +- esphome/components/cap1188/cap1188.h | 4 +- .../captive_portal/captive_portal.h | 2 +- esphome/components/cc1101/cc1101.h | 43 ++++++++++--------- esphome/components/ccs811/ccs811.h | 2 +- esphome/components/cd74hc4067/cd74hc4067.h | 4 +- esphome/components/ch422g/ch422g.h | 4 +- esphome/components/ch423/ch423.h | 4 +- 31 files changed, 73 insertions(+), 70 deletions(-) diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index c719599b998..1d5ac6e7023 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -8,7 +8,7 @@ namespace esphome::b_parasite { -class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c2d48741b1e..c70ee637ef6 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -12,7 +12,9 @@ namespace esphome::ble_scanner { -class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLEScanner final : public text_sensor::TextSensor, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index eda2d1b3c0b..5ca9714091d 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -5,7 +5,7 @@ namespace esphome::bm8563 { -class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { +class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -34,17 +34,17 @@ class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -template class TimerAction : public Action, public Parented { +template class TimerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint32_t, duration) diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index ad4a283fc78..501556d3c40 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -7,7 +7,7 @@ namespace esphome::bme280_i2c { static const char *const TAG = "bme280_i2c.sensor"; -class BME280I2CComponent : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { +class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 4e842e9596c..3879151ea13 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bme280_spi { -class BME280SPIComponent : public esphome::bme280_base::BME280Component, - public spi::SPIDevice { +class BME280SPIComponent final : public esphome::bme280_base::BME280Component, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index e40daf87201..a274578fc18 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -65,7 +65,7 @@ struct BME680CalibrationData { int8_t ambient_temperature; }; -class BME680Component : public PollingComponent, public i2c::I2CDevice { +class BME680Component final : public PollingComponent, public i2c::I2CDevice { public: /// Set the temperature oversampling value. Defaults to 16X. void set_temperature_oversampling(BME680Oversampling temperature_oversampling); diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 742b07b59bf..ff974d1c6f0 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -34,7 +34,7 @@ enum SampleRate { #define BME680_BSEC_SAMPLE_RATE_LOG(r) (r == SAMPLE_RATE_DEFAULT ? "Default" : (r == SAMPLE_RATE_ULP ? "ULP" : "LP")) -class BME680BSECComponent : public Component, public i2c::I2CDevice { +class BME680BSECComponent final : public Component, public i2c::I2CDevice { public: void set_device_id(const std::string &devid) { this->device_id_.assign(devid); } void set_temperature_offset(float offset) { this->temperature_offset_ = offset; } diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h index 6d20b613903..896d00d0968 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h @@ -11,7 +11,7 @@ namespace esphome::bme68x_bsec2_i2c { -class BME68xBSEC2I2CComponent : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { +class BME68xBSEC2I2CComponent final : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index e86c353eaa9..8af25a09add 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -6,7 +6,7 @@ namespace esphome::bmi160 { -class BMI160Component : public PollingComponent, public i2c::I2CDevice { +class BMI160Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 7c5a2db015e..56d6a609522 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -78,7 +78,7 @@ enum BMI270GyroODR : uint8_t { // ---Data class // Main component class -class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { +class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { public: // Lifecycle void setup() override; diff --git a/esphome/components/bmp085/bmp085.h b/esphome/components/bmp085/bmp085.h index a64f3936f0a..70121522576 100644 --- a/esphome/components/bmp085/bmp085.h +++ b/esphome/components/bmp085/bmp085.h @@ -6,7 +6,7 @@ namespace esphome::bmp085 { -class BMP085Component : public PollingComponent, public i2c::I2CDevice { +class BMP085Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_pressure(sensor::Sensor *pressure) { pressure_ = pressure; } diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index bf1c2fd6247..a19203ff0a0 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp280_i2c { static const char *const TAG = "bmp280_i2c.sensor"; /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. -class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { +class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 17d39998849..449167811d3 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bmp280_spi { -class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, - public spi::SPIDevice { +class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, + public spi::SPIDevice { void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h index bec99cf9f8b..93549fc890a 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h @@ -4,7 +4,7 @@ namespace esphome::bmp3xx_i2c { -class BMP3XXI2CComponent : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { +class BMP3XXI2CComponent final : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.h b/esphome/components/bmp3xx_spi/bmp3xx_spi.h index fa0c0e1b477..7e101cc3a1a 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.h +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.h @@ -4,9 +4,9 @@ namespace esphome::bmp3xx_spi { -class BMP3XXSPIComponent : public bmp3xx_base::BMP3XXComponent, - public spi::SPIDevice { +class BMP3XXSPIComponent final : public bmp3xx_base::BMP3XXComponent, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index a4e43daf64c..126ffd6a606 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp581_i2c { static const char *const TAG = "bmp581_i2c.sensor"; /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. -class BMP581I2CComponent : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { +class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h index 57f75588d5e..e5b6cf44761 100644 --- a/esphome/components/bmp581_spi/bmp581_spi.h +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -6,9 +6,9 @@ namespace esphome::bmp581_spi { // BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. -class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, - public spi::SPIDevice { +class BMP581SPIComponent final : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { public: void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bp1658cj/bp1658cj.h b/esphome/components/bp1658cj/bp1658cj.h index 8905642ec46..666a1458049 100644 --- a/esphome/components/bp1658cj/bp1658cj.h +++ b/esphome/components/bp1658cj/bp1658cj.h @@ -7,7 +7,7 @@ namespace esphome::bp1658cj { -class BP1658CJ : public Component { +class BP1658CJ final : public Component { public: class Channel; @@ -29,7 +29,7 @@ class BP1658CJ : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP1658CJ *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bp5758d/bp5758d.h b/esphome/components/bp5758d/bp5758d.h index f07d51fe518..572108b4e67 100644 --- a/esphome/components/bp5758d/bp5758d.h +++ b/esphome/components/bp5758d/bp5758d.h @@ -7,7 +7,7 @@ namespace esphome::bp5758d { -class BP5758D : public Component { +class BP5758D final : public Component { public: class Channel; @@ -23,7 +23,7 @@ class BP5758D : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP5758D *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 9bec8ba7a10..924858e4496 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -12,7 +12,7 @@ namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index 6a54b141a35..d55d43ea370 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -6,7 +6,7 @@ namespace esphome::button { -template class PressAction : public Action { +template class PressAction final : public Action { public: explicit PressAction(Button *button) : button_(button) {} @@ -16,7 +16,7 @@ template class PressAction : public Action { Button *button_; }; -class ButtonPressTrigger : public Trigger<> { +class ButtonPressTrigger final : public Trigger<> { public: ButtonPressTrigger(Button *button) { button->add_on_press_callback([this]() { this->trigger(); }); diff --git a/esphome/components/camera_encoder/encoder_buffer_impl.h b/esphome/components/camera_encoder/encoder_buffer_impl.h index d394daff14f..b506cb47e0d 100644 --- a/esphome/components/camera_encoder/encoder_buffer_impl.h +++ b/esphome/components/camera_encoder/encoder_buffer_impl.h @@ -5,7 +5,7 @@ namespace esphome::camera_encoder { -class EncoderBufferImpl : public camera::EncoderBuffer { +class EncoderBufferImpl final : public camera::EncoderBuffer { public: // --- EncoderBuffer --- bool set_buffer_size(size_t size) override; diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h index 0ede366e73d..5ec6a98cb9d 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h @@ -11,7 +11,7 @@ namespace esphome::camera_encoder { /// Encoder that uses the software-based JPEG implementation from Espressif's esp32-camera component. -class ESP32CameraJPEGEncoder : public camera::Encoder { +class ESP32CameraJPEGEncoder final : public camera::Encoder { public: /// Constructs a ESP32CameraJPEGEncoder instance. /// @param quality Sets the quality of the encoded image (1-100). diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 691d7384f1d..1bc4d6e3455 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -106,7 +106,7 @@ class Canbus : public Component { virtual Error read_message(struct CanFrame *frame) = 0; }; -template class CanbusSendAction : public Action, public Parented { +template class CanbusSendAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers @@ -154,7 +154,7 @@ template class CanbusSendAction : public Action, public P } data_; }; -class CanbusTrigger : public Trigger, uint32_t, bool>, public Component { +class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { friend class Canbus; public: diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index 848e6fe4305..a4abb270e70 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -26,7 +26,7 @@ enum { CAP1188_SENSITVITY = 0x1f, }; -class CAP1188Channel : public binary_sensor::BinarySensor { +class CAP1188Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint8_t data) { this->publish_state(static_cast(data & (1 << this->channel_))); } @@ -35,7 +35,7 @@ class CAP1188Channel : public binary_sensor::BinarySensor { uint8_t channel_{0}; }; -class CAP1188Component : public Component, public i2c::I2CDevice { +class CAP1188Component final : public Component, public i2c::I2CDevice { public: void register_channel(CAP1188Channel *channel) { this->channels_.push_back(channel); } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 8c8b43e608c..b47af9d978a 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -14,7 +14,7 @@ namespace esphome::captive_portal { -class CaptivePortal : public AsyncWebHandler, public Component { +class CaptivePortal final : public AsyncWebHandler, public Component { public: CaptivePortal(web_server_base::WebServerBase *base); void setup() override; diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 000a13d586c..065ffd52503 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -16,9 +16,9 @@ class CC1101Listener { virtual void on_packet(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) = 0; }; -class CC1101Component : public Component, - public spi::SPIDevice { +class CC1101Component final : public Component, + public spi::SPIDevice { public: CC1101Component(); @@ -119,27 +119,27 @@ class CC1101Component : public Component, }; // Action Wrappers -template class BeginTxAction : public Action, public Parented { +template class BeginTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_tx(); } }; -template class BeginRxAction : public Action, public Parented { +template class BeginRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_rx(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetIdleAction : public Action, public Parented { +template class SetIdleAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_idle(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } void set_data_static(const uint8_t *data, size_t len) { @@ -163,79 +163,80 @@ template class SendPacketAction : public Action, public P size_t data_static_len_{0}; }; -template class SetSymbolRateAction : public Action, public Parented { +template class SetSymbolRateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, symbol_rate) void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } }; -template class SetFrequencyAction : public Action, public Parented { +template class SetFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, frequency) void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } }; -template class SetOutputPowerAction : public Action, public Parented { +template class SetOutputPowerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, output_power) void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } }; -template class SetModulationTypeAction : public Action, public Parented { +template class SetModulationTypeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Modulation, modulation_type) void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } }; -template class SetRxAttenuationAction : public Action, public Parented { +template class SetRxAttenuationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } }; -template class SetDcBlockingFilterAction : public Action, public Parented { +template +class SetDcBlockingFilterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, dc_blocking_filter) void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } }; -template class SetManchesterAction : public Action, public Parented { +template class SetManchesterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, manchester) void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } }; -template class SetFilterBandwidthAction : public Action, public Parented { +template class SetFilterBandwidthAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, filter_bandwidth) void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } }; -template class SetFskDeviationAction : public Action, public Parented { +template class SetFskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, fsk_deviation) void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } }; -template class SetMskDeviationAction : public Action, public Parented { +template class SetMskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, msk_deviation) void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } }; -template class SetChannelSpacingAction : public Action, public Parented { +template class SetChannelSpacingAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, channel_spacing) void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } }; -template class SetIfFrequencyAction : public Action, public Parented { +template class SetIfFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, if_frequency) void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index fde24947532..fb83f842fd9 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -8,7 +8,7 @@ namespace esphome::ccs811 { -class CCS811Component : public PollingComponent, public i2c::I2CDevice { +class CCS811Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/cd74hc4067/cd74hc4067.h b/esphome/components/cd74hc4067/cd74hc4067.h index f41b5e294ab..3e773a3c8c6 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.h +++ b/esphome/components/cd74hc4067/cd74hc4067.h @@ -7,7 +7,7 @@ namespace esphome::cd74hc4067 { -class CD74HC4067Component : public Component { +class CD74HC4067Component final : public Component { public: /// Set up the internal sensor array. void setup() override; @@ -38,7 +38,7 @@ class CD74HC4067Component : public Component { uint32_t switch_delay_; }; -class CD74HC4067Sensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class CD74HC4067Sensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: CD74HC4067Sensor(CD74HC4067Component *parent); diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index f74e0c46a47..a8729225e80 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -6,7 +6,7 @@ namespace esphome::ch422g { -class CH422GComponent : public Component, public i2c::I2CDevice { +class CH422GComponent final : public Component, public i2c::I2CDevice { public: CH422GComponent() = default; @@ -42,7 +42,7 @@ class CH422GComponent : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin : public GPIOPin { +class CH422GGPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index d384971a72e..fbfffb521d2 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -6,7 +6,7 @@ namespace esphome::ch423 { -class CH423Component : public Component, public i2c::I2CDevice { +class CH423Component final : public Component, public i2c::I2CDevice { public: CH423Component() = default; @@ -41,7 +41,7 @@ class CH423Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin : public GPIOPin { +class CH423GPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; From faabafad2b7818f6c40ad9392096f9e7766819a9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:54:05 +1000 Subject: [PATCH 102/343] [mipi_rgb] Fix offsets for Wave 5 1024x600 (#17057) --- esphome/components/mipi/__init__.py | 5 +++++ tests/components/mipi_rgb/test.esp32-s3-idf.yaml | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 129befe600d..caa33cd834e 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -322,6 +322,9 @@ class DriverChip: - defaults.get(CONF_OFFSET_WIDTH, 0) - defaults.get(CONF_PAD_WIDTH, 0) ) + elif defaults[CONF_WIDTH] > defaults[CONF_NATIVE_WIDTH]: + defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] + else: native_width = ( defaults.get(CONF_WIDTH, 0) @@ -337,6 +340,8 @@ class DriverChip: - defaults.get(CONF_OFFSET_HEIGHT, 0) - defaults.get(CONF_PAD_HEIGHT, 0) ) + elif defaults[CONF_HEIGHT] > defaults[CONF_NATIVE_HEIGHT]: + defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] else: native_height = ( defaults.get(CONF_HEIGHT, 0) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 399c25c1d07..b56ebee21e5 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,7 +1,11 @@ packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + - !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal -<<: !include common.yaml +ch422g: + +display: + - platform: mipi_rgb + model: WAVESHARE-5-1024X600 From 44c54b3a756692618a4245b8384b3eecff412b30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:09:54 -0500 Subject: [PATCH 103/343] [json] Bump ArduinoJson to 7.4.3 (#17126) --- esphome/components/json/__init__.py | 4 ++-- esphome/idf_component.yml | 2 +- platformio.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 28fdcd41efc..3cb89a6cd9f 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -15,8 +15,8 @@ async def to_code(config): if CORE.is_esp32: from esphome.components.esp32 import add_idf_component - add_idf_component(name="bblanchon/arduinojson", ref="7.4.2") + add_idf_component(name="bblanchon/arduinojson", ref="7.4.3") else: - cg.add_library("bblanchon/ArduinoJson", "7.4.2") + cg.add_library("bblanchon/ArduinoJson", "7.4.3") cg.add_define("USE_JSON") cg.add_global(json_ns.using) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3b670d77b4..f8f3df57cd0 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,6 @@ dependencies: bblanchon/arduinojson: - version: "7.4.2" + version: "7.4.3" esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: diff --git a/platformio.ini b/platformio.ini index 862b7a7dbe9..43a7474d350 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,7 +104,7 @@ build_unflags = [common:idf-component-libs] lib_deps = esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + bblanchon/ArduinoJson@7.4.3 ; json lvgl/lvgl@9.5.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. From 7fcc890e84093d3562c9b158d0476e62a8f89116 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 20:02:46 -0500 Subject: [PATCH 104/343] [rp2040] Bump arduino-pico framework to 5.6.1 (#17122) --- esphome/components/rp2040/__init__.py | 7 ++- esphome/components/rp2040/boards.py | 68 +++++++++++++++++++++++++++ platformio.ini | 2 +- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index dd851b8e168..e76ce6def88 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -187,12 +187,11 @@ def _parse_platform_version(value): # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags @@ -202,8 +201,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 6, 0), None), + "dev": (cv.Version(5, 6, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 6, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2040/boards.py index 1f2b3a93f43..0bc5c48d033 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2040/boards.py @@ -865,6 +865,30 @@ RP2040_BOARD_PINS = { "SS": 17, "TX": 0, }, + "pcbcupid_glyph_2040": { + "LED": 0, + "MISO": 8, + "MOSI": 7, + "RX": 13, + "SCK": 6, + "SCL": 21, + "SDA": 20, + "SS": 5, + "TX": 12, + }, + "pcbcupid_glyph_mini_2040": { + "LED": 16, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 9, + "SCL1": 27, + "SDA": 8, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, "picolume": { "LED": 25, "MISO": 16, @@ -1079,6 +1103,18 @@ RP2040_BOARD_PINS = { "SDA": 6, "TX": 0, }, + "seeed_xiao_rp2040_plus": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 7, + "SCL1": 21, + "SDA": 6, + "SDA1": 20, + "TX": 0, + }, "seeed_xiao_rp2350": { "LED": 25, "MISO": 4, @@ -1102,6 +1138,18 @@ RP2040_BOARD_PINS = { "SS": 21, "TX": 0, }, + "soldered_nula_ethernet_w55rp20": { + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 3, + "SCL1": 29, + "SDA": 2, + "SDA1": 28, + "SS": 5, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -1899,6 +1947,16 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "pcbcupid_glyph_2040": { + "name": "PCBCupid Glyph 2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pcbcupid_glyph_mini_2040": { + "name": "PCBCupid Glyph Mini 2040", + "mcu": "rp2040", + "max_pin": 29, + }, "picolume": { "name": "PicoLume Transceiver", "mcu": "rp2040", @@ -2021,6 +2079,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "seeed_xiao_rp2040_plus": { + "name": "Seeed XIAO RP2040 Plus", + "mcu": "rp2040", + "max_pin": 29, + }, "seeed_xiao_rp2350": { "name": "Seeed XIAO RP2350", "mcu": "rp2350", @@ -2031,6 +2094,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_ethernet_w55rp20": { + "name": "Soldered Electronics NULA Ethernet W55RP20", + "mcu": "rp2040", + "max_pin": 29, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", diff --git a/platformio.ini b/platformio.ini index 43a7474d350..bca29106167 100644 --- a/platformio.ini +++ b/platformio.ini @@ -206,7 +206,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.0/rp2040-5.6.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.1/rp2040-5.6.1.zip framework = arduino lib_deps = From 026bac4cd1efeed4971a41bcc381553d40e7c053 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:27:56 +1200 Subject: [PATCH 105/343] [ld2420] Mark configurable classes as final (#17130) --- .../ld2420/binary_sensor/ld2420_binary_sensor.h | 2 +- .../components/ld2420/button/reconfig_buttons.h | 8 ++++---- esphome/components/ld2420/ld2420.h | 2 +- .../ld2420/number/gate_config_number.h | 16 ++++++++-------- .../ld2420/select/operating_mode_select.h | 2 +- esphome/components/ld2420/sensor/ld2420_sensor.h | 2 +- .../ld2420/text_sensor/ld2420_text_sensor.h | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h index ec52312f92d..47492e38c2e 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor { +class LD2420BinarySensor final : public LD2420Listener, public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void set_presence_sensor(binary_sensor::BinarySensor *bsensor) { this->presence_bsensor_ = bsensor; }; diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h index 72171ef3869..b769e18a461 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.h +++ b/esphome/components/ld2420/button/reconfig_buttons.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420ApplyConfigButton final : public button::Button, public Parented { public: LD2420ApplyConfigButton() = default; @@ -13,7 +13,7 @@ class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420RevertConfigButton final : public button::Button, public Parented { public: LD2420RevertConfigButton() = default; @@ -21,7 +21,7 @@ class LD2420RevertConfigButton : public button::Button, public Parented { +class LD2420RestartModuleButton final : public button::Button, public Parented { public: LD2420RestartModuleButton() = default; @@ -29,7 +29,7 @@ class LD2420RestartModuleButton : public button::Button, public Parented { +class LD2420FactoryResetButton final : public button::Button, public Parented { public: LD2420FactoryResetButton() = default; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 358793fe64f..ae44b160650 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -40,7 +40,7 @@ class LD2420Listener { virtual void on_fw_version(std::string &fw){}; }; -class LD2420Component : public Component, public uart::UARTDevice { +class LD2420Component final : public Component, public uart::UARTDevice { public: struct CmdFrameT { uint32_t header{0}; diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index 8a8b9c61b16..e1c12e023a9 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420TimeoutNumber final : public number::Number, public Parented { public: LD2420TimeoutNumber() = default; @@ -13,7 +13,7 @@ class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420MinDistanceNumber final : public number::Number, public Parented { public: LD2420MinDistanceNumber() = default; @@ -21,7 +21,7 @@ class LD2420MinDistanceNumber : public number::Number, public Parented { +class LD2420MaxDistanceNumber final : public number::Number, public Parented { public: LD2420MaxDistanceNumber() = default; @@ -29,7 +29,7 @@ class LD2420MaxDistanceNumber : public number::Number, public Parented { +class LD2420GateSelectNumber final : public number::Number, public Parented { public: LD2420GateSelectNumber() = default; @@ -37,7 +37,7 @@ class LD2420GateSelectNumber : public number::Number, public Parented { +class LD2420MoveSensFactorNumber final : public number::Number, public Parented { public: LD2420MoveSensFactorNumber() = default; @@ -45,7 +45,7 @@ class LD2420MoveSensFactorNumber : public number::Number, public Parented { +class LD2420StillSensFactorNumber final : public number::Number, public Parented { public: LD2420StillSensFactorNumber() = default; @@ -53,7 +53,7 @@ class LD2420StillSensFactorNumber : public number::Number, public Parented { +class LD2420StillThresholdNumbers final : public number::Number, public Parented { public: LD2420StillThresholdNumbers() = default; LD2420StillThresholdNumbers(uint8_t gate); @@ -63,7 +63,7 @@ class LD2420StillThresholdNumbers : public number::Number, public Parented { +class LD2420MoveThresholdNumbers final : public number::Number, public Parented { public: LD2420MoveThresholdNumbers() = default; LD2420MoveThresholdNumbers(uint8_t gate); diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index c1b8e0b11be..e5eb5d82bd9 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Select : public Component, public select::Select, public Parented { +class LD2420Select final : public Component, public select::Select, public Parented { public: LD2420Select() = default; diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h index 4849cfa0477..4ccfc190815 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.h +++ b/esphome/components/ld2420/sensor/ld2420_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { +class LD2420Sensor final : public LD2420Listener, public Component, public sensor::Sensor { public: void dump_config() override; void set_distance_sensor(sensor::Sensor *sensor) { this->distance_sensor_ = sensor; } diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h index 1932eaaf69a..da295fe7ca9 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor { +class LD2420TextSensor final : public LD2420Listener, public Component, public text_sensor::TextSensor { public: void dump_config() override; void set_fw_version_text_sensor(text_sensor::TextSensor *tsensor) { this->fw_version_text_sensor_ = tsensor; }; From 2982d7c83499a552089dc4dca35bd44087577467 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:16 +1200 Subject: [PATCH 106/343] Mark configurable classes as final (9/21) (#16960) --- .../internal_temperature.h | 2 +- esphome/components/interval/interval.h | 2 +- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 4 ++-- esphome/components/jsn_sr04t/jsn_sr04t.h | 2 +- .../components/kamstrup_kmp/kamstrup_kmp.h | 2 +- .../components/key_collector/key_collector.h | 6 +++--- esphome/components/kmeteriso/kmeteriso.h | 2 +- esphome/components/kuntze/kuntze.h | 2 +- esphome/components/lc709203f/lc709203f.h | 2 +- .../components/lcd_gpio/gpio_lcd_display.h | 2 +- esphome/components/lcd_menu/lcd_menu.h | 2 +- .../components/lcd_pcf8574/pcf8574_display.h | 2 +- esphome/components/ld2410/automation.h | 2 +- .../ld2410/button/factory_reset_button.h | 2 +- .../components/ld2410/button/query_button.h | 2 +- .../components/ld2410/button/restart_button.h | 2 +- esphome/components/ld2410/ld2410.h | 2 +- .../ld2410/number/gate_threshold_number.h | 2 +- .../ld2410/number/light_threshold_number.h | 2 +- .../number/max_distance_timeout_number.h | 2 +- .../ld2410/select/baud_rate_select.h | 2 +- .../select/distance_resolution_select.h | 2 +- .../ld2410/select/light_out_control_select.h | 2 +- .../ld2410/switch/bluetooth_switch.h | 2 +- .../ld2410/switch/engineering_mode_switch.h | 2 +- .../ld2412/button/factory_reset_button.h | 2 +- .../components/ld2412/button/query_button.h | 2 +- .../components/ld2412/button/restart_button.h | 2 +- ...art_dynamic_background_correction_button.h | 2 +- esphome/components/ld2412/ld2412.h | 2 +- .../ld2412/number/gate_threshold_number.h | 2 +- .../ld2412/number/light_threshold_number.h | 2 +- .../number/max_distance_timeout_number.h | 2 +- .../ld2412/select/baud_rate_select.h | 2 +- .../select/distance_resolution_select.h | 2 +- .../ld2412/select/light_out_control_select.h | 2 +- .../ld2412/switch/bluetooth_switch.h | 2 +- .../ld2412/switch/engineering_mode_switch.h | 2 +- esphome/components/ledc/ledc_output.h | 4 ++-- esphome/components/libretiny/gpio_arduino.h | 2 +- esphome/components/libretiny/lt_component.h | 2 +- .../components/libretiny_pwm/libretiny_pwm.h | 4 ++-- esphome/components/light/addressable_light.h | 2 +- esphome/components/light/automation.h | 20 +++++++++---------- esphome/components/lightwaverf/lightwaverf.h | 4 ++-- .../touchscreen/lilygo_t5_47_touchscreen.h | 2 +- esphome/components/lm75b/lm75b.h | 2 +- esphome/components/lock/automation.h | 8 ++++---- esphome/components/lps22/lps22.h | 2 +- esphome/components/lsm6ds/lsm6ds.h | 2 +- esphome/components/ltr390/ltr390.h | 2 +- esphome/components/ltr501/ltr501.h | 2 +- esphome/components/lvgl/light/lvgl_light.h | 2 +- esphome/components/lvgl/lvgl_esphome.h | 16 +++++++-------- esphome/components/lvgl/number/lvgl_number.h | 2 +- esphome/components/lvgl/select/lvgl_select.h | 2 +- esphome/components/lvgl/switch/lvgl_switch.h | 2 +- esphome/components/lvgl/text/lvgl_text.h | 2 +- .../m5stack_8angle_binary_sensor.h | 6 +++--- .../light/m5stack_8angle_light.h | 2 +- .../m5stack_8angle/m5stack_8angle.h | 2 +- .../sensor/m5stack_8angle_sensor.h | 6 +++--- 62 files changed, 91 insertions(+), 91 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 41fea5a255f..90831cf211d 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -11,7 +11,7 @@ namespace esphome::internal_temperature { -class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { +class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index c9d4e8ea3e8..fd59d2a4880 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -6,7 +6,7 @@ namespace esphome::interval { -class IntervalTrigger : public Trigger<>, public PollingComponent { +class IntervalTrigger final : public Trigger<>, public PollingComponent { public: void update() override { this->trigger(); } diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index d0467e822d1..5fc683354ba 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -18,7 +18,7 @@ namespace esphome::ir_rf_proxy { #ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend -class IrRfProxy : public infrared::Infrared { +class IrRfProxy final : public infrared::Infrared { public: IrRfProxy() = default; @@ -47,7 +47,7 @@ class IrRfProxy : public infrared::Infrared { /// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done /// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and /// to this entity's on_control trigger (see radio_frequency component docs). -class RfProxy : public radio_frequency::RadioFrequency { +class RfProxy final : public radio_frequency::RadioFrequency { public: RfProxy() = default; diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.h b/esphome/components/jsn_sr04t/jsn_sr04t.h index f9d07ea5393..5368ec683ce 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.h +++ b/esphome/components/jsn_sr04t/jsn_sr04t.h @@ -13,7 +13,7 @@ enum Model { AJ_SR04M, }; -class Jsnsr04tComponent : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { +class Jsnsr04tComponent final : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { public: void set_model(Model model) { this->model_ = model; } diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index a4eacec4532..57a89f77a1c 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -73,7 +73,7 @@ static const char *const UNITS[] = { "mm:dd", "", "bar", "RTC", "ASCII", "m3 x 10", "ton x 10", "GJ x 10", "minutes", "Bitfield", "s", "ms", "days", "RTC-Q", "Datetime"}; -class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice { +class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDevice { public: void set_heat_energy_sensor(sensor::Sensor *sensor) { this->heat_energy_sensor_ = sensor; } void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; } diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index 27209c50df5..c9eeabeb2de 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -7,7 +7,7 @@ namespace esphome::key_collector { -class KeyCollector : public Component { +class KeyCollector final : public Component { public: void loop() override; void dump_config() override; @@ -54,11 +54,11 @@ class KeyCollector : public Component { bool enabled_{}; }; -template class EnableAction : public Action, public Parented { +template class EnableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(true); } }; -template class DisableAction : public Action, public Parented { +template class DisableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(false); } }; diff --git a/esphome/components/kmeteriso/kmeteriso.h b/esphome/components/kmeteriso/kmeteriso.h index d5a2f9a01b1..bd92a6011f6 100644 --- a/esphome/components/kmeteriso/kmeteriso.h +++ b/esphome/components/kmeteriso/kmeteriso.h @@ -8,7 +8,7 @@ namespace esphome::kmeteriso { /// This class implements support for the KMeterISO thermocouple sensor. -class KMeterISOComponent : public PollingComponent, public i2c::I2CDevice { +class KMeterISOComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *t) { this->temperature_sensor_ = t; } void set_internal_temperature_sensor(sensor::Sensor *t) { this->internal_temperature_sensor_ = t; } diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index bbd93a22cec..99dd78e5b60 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 42aa9a15a10..46f773873af 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -19,7 +19,7 @@ enum LC709203FBatteryVoltage { LC709203F_BATTERY_VOLTAGE_3_7 = 0x0001, }; -class Lc709203f : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Lc709203f final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.h b/esphome/components/lcd_gpio/gpio_lcd_display.h index dd9ea5929cd..17fcf7ea238 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.h +++ b/esphome/components/lcd_gpio/gpio_lcd_display.h @@ -10,7 +10,7 @@ class GPIOLCDDisplay; using gpio_lcd_writer_t = display::DisplayWriter; -class GPIOLCDDisplay : public lcd_base::LCDDisplay { +class GPIOLCDDisplay final : public lcd_base::LCDDisplay { public: void set_writer(gpio_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/lcd_menu/lcd_menu.h b/esphome/components/lcd_menu/lcd_menu.h index ae1c2502fea..6fa61fdf6aa 100644 --- a/esphome/components/lcd_menu/lcd_menu.h +++ b/esphome/components/lcd_menu/lcd_menu.h @@ -11,7 +11,7 @@ namespace esphome::lcd_menu { /** Class to display a hierarchical menu. * */ -class LCDCharacterMenuComponent : public display_menu_base::DisplayMenuComponent { +class LCDCharacterMenuComponent final : public display_menu_base::DisplayMenuComponent { public: void set_display(lcd_base::LCDDisplay *display) { this->display_ = display; } void set_dimensions(uint8_t columns, uint8_t rows) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.h b/esphome/components/lcd_pcf8574/pcf8574_display.h index 9ec5ad71af1..5af087add52 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.h +++ b/esphome/components/lcd_pcf8574/pcf8574_display.h @@ -11,7 +11,7 @@ class PCF8574LCDDisplay; using pcf8574_lcd_writer_t = display::DisplayWriter; -class PCF8574LCDDisplay : public lcd_base::LCDDisplay, public i2c::I2CDevice { +class PCF8574LCDDisplay final : public lcd_base::LCDDisplay, public i2c::I2CDevice { public: void set_writer(pcf8574_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h index 614453b575c..b0b9591d378 100644 --- a/esphome/components/ld2410/automation.h +++ b/esphome/components/ld2410/automation.h @@ -6,7 +6,7 @@ namespace esphome::ld2410 { -template class BluetoothPasswordSetAction : public Action { +template class BluetoothPasswordSetAction final : public Action { public: explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/ld2410/button/factory_reset_button.h b/esphome/components/ld2410/button/factory_reset_button.h index 715a8c40567..1da7c813375 100644 --- a/esphome/components/ld2410/button/factory_reset_button.h +++ b/esphome/components/ld2410/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2410/button/query_button.h b/esphome/components/ld2410/button/query_button.h index 7a786901aec..4f3f147e673 100644 --- a/esphome/components/ld2410/button/query_button.h +++ b/esphome/components/ld2410/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2410/button/restart_button.h b/esphome/components/ld2410/button/restart_button.h index 9bf8639a8cd..70e0a74c9a5 100644 --- a/esphome/components/ld2410/button/restart_button.h +++ b/esphome/components/ld2410/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 31186b135f2..a0cce36d16d 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -38,7 +38,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 50; static constexpr uint8_t TOTAL_GATES = 9; // Total number of gates supported by the LD2410 -class LD2410Component : public Component, public uart::UARTDevice { +class LD2410Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(out_pin_presence_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2410/number/gate_threshold_number.h b/esphome/components/ld2410/number/gate_threshold_number.h index 63491f18d3c..68359a10d4f 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.h +++ b/esphome/components/ld2410/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2410/number/light_threshold_number.h b/esphome/components/ld2410/number/light_threshold_number.h index 3c5e4334163..6e1a5ca4a4b 100644 --- a/esphome/components/ld2410/number/light_threshold_number.h +++ b/esphome/components/ld2410/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2410/number/max_distance_timeout_number.h b/esphome/components/ld2410/number/max_distance_timeout_number.h index 35f4cbbfae0..29b19c2022b 100644 --- a/esphome/components/ld2410/number/max_distance_timeout_number.h +++ b/esphome/components/ld2410/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index fb1d016b1f1..b06ce139ad3 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index be2389d36ed..0c5409b7b17 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index 608c311af4f..a8a16598b18 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2410/switch/bluetooth_switch.h b/esphome/components/ld2410/switch/bluetooth_switch.h index 07804e2292a..cc56b2cda03 100644 --- a/esphome/components/ld2410/switch/bluetooth_switch.h +++ b/esphome/components/ld2410/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.h b/esphome/components/ld2410/switch/engineering_mode_switch.h index 4dd8e16653b..49243a73adc 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.h +++ b/esphome/components/ld2410/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h index 1ef6b23b804..a6ea8f7365d 100644 --- a/esphome/components/ld2412/button/factory_reset_button.h +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h index 373e1358021..71e2ab14e88 100644 --- a/esphome/components/ld2412/button/query_button.h +++ b/esphome/components/ld2412/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h index 80c79f5e7de..668ce1a8e6f 100644 --- a/esphome/components/ld2412/button/restart_button.h +++ b/esphome/components/ld2412/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h index b1f21278964..3b24f5dcf8b 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { +class StartDynamicBackgroundCorrectionButton final : public button::Button, public Parented { public: StartDynamicBackgroundCorrectionButton() = default; diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 306e7ae31d2..f722f938ae4 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -36,7 +36,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 54; // Max characters for serial buffer static constexpr uint8_t TOTAL_GATES = 14; // Total number of gates supported by the LD2412 -class LD2412Component : public Component, public uart::UARTDevice { +class LD2412Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(dynamic_background_correction_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 78c2e54d821..918b6dfad1a 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index 81fd73111c3..f62d523af38 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h index c1e947fa190..4a3478d48a1 100644 --- a/esphome/components/ld2412/number/max_distance_timeout_number.h +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 4666dd2fa0a..46ec9be1d1e 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index d3b7fad2f98..be8dba90b5d 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index 9f861898787..c8988fda78e 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 0c0d1fa5505..8fd4a86e43c 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index 4e75a8a185f..defeb4c76ba 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index bf5cdb93055..b0a243f2e43 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -13,7 +13,7 @@ namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; -class LEDCOutput : public output::FloatOutput, public Component { +class LEDCOutput final : public output::FloatOutput, public Component { public: explicit LEDCOutput(InternalGPIOPin *pin) : pin_(pin) { this->channel_ = next_ledc_channel++; } @@ -43,7 +43,7 @@ class LEDCOutput : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 5f1fa3fec76..da477fde363 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -5,7 +5,7 @@ namespace esphome::libretiny { -class ArduinoInternalGPIOPin : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/libretiny/lt_component.h b/esphome/components/libretiny/lt_component.h index 896f1901e3c..3850a679b29 100644 --- a/esphome/components/libretiny/lt_component.h +++ b/esphome/components/libretiny/lt_component.h @@ -14,7 +14,7 @@ namespace esphome::libretiny { -class LTComponent : public Component { +class LTComponent final : public Component { public: float get_setup_priority() const override; void dump_config() override; diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f7737be386b..f0ea0228b7d 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -9,7 +9,7 @@ namespace esphome::libretiny_pwm { -class LibreTinyPWM : public output::FloatOutput, public Component { +class LibreTinyPWM final : public output::FloatOutput, public Component { public: explicit LibreTinyPWM(InternalGPIOPin *pin) : pin_(pin) {} @@ -34,7 +34,7 @@ class LibreTinyPWM : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 0202ad380a8..57e9caf289c 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -21,7 +21,7 @@ Color color_from_light_color_values(LightColorValues val); /// Use a custom state class for addressable lights, to allow type system to discriminate between addressable and /// non-addressable lights. -class AddressableLightState : public LightState { +class AddressableLightState final : public LightState { using LightState::LightState; }; diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 260414f0330..ced15dfc603 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,7 +8,7 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} @@ -43,7 +43,7 @@ template class ToggleAction : public A // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction : public Action { +template class LightControlAction final : public Action { public: using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} @@ -59,7 +59,7 @@ template class LightControlAction : public Action { ApplyFn apply_; }; -template class DimRelativeAction : public Action { +template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} @@ -108,7 +108,7 @@ template class DimRelativeAction : pub // at compile time so the chosen branch is the only one that gets instantiated // per action site. `include_none` is runtime so a single set of templates // covers both the "wrap through None" and "skip None" variants. -template class LightEffectCycleAction : public Action { +template class LightEffectCycleAction final : public Action { public: explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} @@ -145,7 +145,7 @@ template class LightEffectCycleAction : public Act bool include_none_{false}; }; -template class LightIsOnCondition : public Condition { +template class LightIsOnCondition final : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } @@ -153,7 +153,7 @@ template class LightIsOnCondition : public Condition { protected: LightState *state_; }; -template class LightIsOffCondition : public Condition { +template class LightIsOffCondition final : public Condition { public: explicit LightIsOffCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } @@ -162,7 +162,7 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { a_light->add_remote_values_listener(this); @@ -187,7 +187,7 @@ class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { bool last_on_; }; -class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { +class LightTurnOffTrigger final : public Trigger<>, public LightTargetStateReachedListener { public: explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { a_light->add_target_state_reached_listener(this); @@ -205,7 +205,7 @@ class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedList LightState *light_; }; -class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightStateTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } @@ -216,7 +216,7 @@ class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { // due to the template. It's just a temporary warning anyway. void addressableset_warn_about_scale(const char *field); -template class AddressableSet : public Action { +template class AddressableSet final : public Action { public: explicit AddressableSet(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 224da6315f4..36dac3c86fc 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -15,7 +15,7 @@ namespace esphome::lightwaverf { #ifdef USE_ESP8266 -class LightWaveRF : public PollingComponent { +class LightWaveRF final : public PollingComponent { public: void set_pin(InternalGPIOPin *pin_tx, InternalGPIOPin *pin_rx) { pin_tx_ = pin_tx; @@ -37,7 +37,7 @@ class LightWaveRF : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction : public Action { +template class SendRawAction final : public Action { public: SendRawAction(LightWaveRF *parent) : parent_(parent){}; TEMPLATABLE_VALUE(int, repeat); diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h index 8b345515ab0..ad82e6c3a0b 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h @@ -12,7 +12,7 @@ namespace esphome::lilygo_t5_47 { using namespace touchscreen; -class LilygoT547Touchscreen : public Touchscreen, public i2c::I2CDevice { +class LilygoT547Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/lm75b/lm75b.h b/esphome/components/lm75b/lm75b.h index eaf1b46550e..3d5b97ae5b4 100644 --- a/esphome/components/lm75b/lm75b.h +++ b/esphome/components/lm75b/lm75b.h @@ -8,7 +8,7 @@ namespace esphome::lm75b { static const uint8_t LM75B_REG_TEMPERATURE = 0x00; -class LM75BComponent : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class LM75BComponent final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index c140bc568fb..ec6ead79f38 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::lock { -template class LockAction : public Action { +template class LockAction final : public Action { public: explicit LockAction(Lock *a_lock) : lock_(a_lock) {} @@ -16,7 +16,7 @@ template class LockAction : public Action { Lock *lock_; }; -template class UnlockAction : public Action { +template class UnlockAction final : public Action { public: explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} @@ -26,7 +26,7 @@ template class UnlockAction : public Action { Lock *lock_; }; -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} @@ -36,7 +36,7 @@ template class OpenAction : public Action { Lock *lock_; }; -template class LockCondition : public Condition { +template class LockCondition final : public Condition { public: LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index c6746f23433..020c14296cf 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -6,7 +6,7 @@ namespace esphome::lps22 { -class LPS22Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class LPS22Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 75462ff1fb2..47d6f55939e 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -82,7 +82,7 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── -class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { +class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 1ead84b4a86..1e3b6494bb9 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -39,7 +39,7 @@ enum LTR390RESOLUTION { LTR390_RESOLUTION_13BIT, }; -class LTR390Component : public PollingComponent, public i2c::I2CDevice { +class LTR390Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index c7eccbeea96..d1f7648d4c5 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -19,7 +19,7 @@ enum LtrType : uint8_t { LTR_TYPE_ALS_AND_PS = 3, }; -class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { +class LTRAlsPs501Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index bf019964c7b..37dc4135ce6 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVLight : public light::LightOutput { +class LVLight final : public light::LightOutput { public: light::LightTraits get_traits() override { auto traits = light::LightTraits(); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3f7f1dce14e..8840b0ad30f 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -155,7 +155,7 @@ class LvPageType : public Parented { using event_callback_t = void(lv_event_t *); -class LvLambdaComponent : public Component { +class LvLambdaComponent final : public Component { public: LvLambdaComponent(void (*callback)()) : callback_(callback) {} @@ -167,7 +167,7 @@ class LvLambdaComponent : public Component { void (*callback_)(); }; -template class ObjUpdateAction : public Action { +template class ObjUpdateAction final : public Action { public: explicit ObjUpdateAction(std::function &&lamb) : lamb_(std::move(lamb)) {} @@ -185,7 +185,7 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; -class LvglComponent : public PollingComponent { +class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; public: @@ -339,7 +339,7 @@ class LvglComponent : public PollingComponent { #endif }; -class IdleTrigger : public Trigger<> { +class IdleTrigger final : public Trigger<> { public: explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); @@ -348,7 +348,7 @@ class IdleTrigger : public Trigger<> { bool is_idle_{}; }; -template class LvglAction : public Action, public Parented { +template class LvglAction final : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} @@ -357,7 +357,7 @@ template class LvglAction : public Action, public Parente std::function action_{}; }; -template class LvglCondition : public Condition, public Parented { +template class LvglCondition final : public Condition, public Parented { public: LvglCondition(std::function &&condition_lambda) : condition_lambda_(std::move(condition_lambda)) {} bool check(const Ts &...x) override { return this->condition_lambda_(this->parent_); } @@ -367,7 +367,7 @@ template class LvglCondition : public Condition { +class LVTouchListener final : public touchscreen::TouchListener, public Parented { public: LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent); void update(const touchscreen::TouchPoints_t &tpoints) override; @@ -403,7 +403,7 @@ class IndicatorLine : public LvCompound { #endif #ifdef USE_LVGL_KEY_LISTENER -class LVEncoderListener : public Parented { +class LVEncoderListener final : public Parented { public: LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time); diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 3fda9427c50..eb2f70b4da1 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -8,7 +8,7 @@ namespace esphome::lvgl { -class LVGLNumber : public number::Number, public Component { +class LVGLNumber final : public number::Number, public Component { public: LVGLNumber(std::function control_lambda, std::function value_lambda, bool restore) : control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {} diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index ffbe29d701e..e36357328c2 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -10,7 +10,7 @@ namespace esphome::lvgl { -class LVGLSelect : public select::Select, public Component { +class LVGLSelect final : public select::Select, public Component { public: LVGLSelect(LvSelectable *widget, lv_anim_enable_t anim, bool restore) : widget_(widget), anim_(anim), restore_(restore) {} diff --git a/esphome/components/lvgl/switch/lvgl_switch.h b/esphome/components/lvgl/switch/lvgl_switch.h index 8f5502a7d5e..ea15767ba8c 100644 --- a/esphome/components/lvgl/switch/lvgl_switch.h +++ b/esphome/components/lvgl/switch/lvgl_switch.h @@ -9,7 +9,7 @@ namespace esphome::lvgl { -class LVGLSwitch : public switch_::Switch, public Component { +class LVGLSwitch final : public switch_::Switch, public Component { public: LVGLSwitch(std::function state_lambda) : state_lambda_(std::move(state_lambda)) {} diff --git a/esphome/components/lvgl/text/lvgl_text.h b/esphome/components/lvgl/text/lvgl_text.h index fead48d6fe1..8d83f323abd 100644 --- a/esphome/components/lvgl/text/lvgl_text.h +++ b/esphome/components/lvgl/text/lvgl_text.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVGLText : public text::Text { +class LVGLText final : public text::Text { public: void set_control_lambda(const std::function &control_lambda) { this->control_lambda_ = control_lambda; diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h index 14400bcea17..a49f6dbb54f 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleSwitchBinarySensor : public binary_sensor::BinarySensor, - public PollingComponent, - public Parented { +class M5Stack8AngleSwitchBinarySensor final : public binary_sensor::BinarySensor, + public PollingComponent, + public Parented { public: void update() override; }; diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h index 0a5a50f2a86..ee204c239b4 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h @@ -10,7 +10,7 @@ namespace esphome::m5stack_8angle { static const uint8_t M5STACK_8ANGLE_NUM_LEDS = 9; static const uint8_t M5STACK_8ANGLE_BYTES_PER_LED = 4; -class M5Stack8AngleLightOutput : public light::AddressableLight, public Parented { +class M5Stack8AngleLightOutput final : public light::AddressableLight, public Parented { public: void setup() override; diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.h b/esphome/components/m5stack_8angle/m5stack_8angle.h index ab2e232204e..058949cad54 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.h +++ b/esphome/components/m5stack_8angle/m5stack_8angle.h @@ -16,7 +16,7 @@ enum AnalogBits : uint8_t { BITS_12 = 12, }; -class M5Stack8AngleComponent : public i2c::I2CDevice, public Component { +class M5Stack8AngleComponent final : public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h index 418503d7c87..a270661ad6b 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleKnobSensor : public sensor::Sensor, - public PollingComponent, - public Parented { +class M5Stack8AngleKnobSensor final : public sensor::Sensor, + public PollingComponent, + public Parented { public: void update() override; void set_channel(uint8_t channel) { this->channel_ = channel; }; From 089147328057c1dcb82d652bd7a6ac77f9537d3f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:34 +1200 Subject: [PATCH 107/343] Mark configurable classes as final (10/21: matrix_keypad-micronova) (#16961) --- .../matrix_keypad_binary_sensor.h | 2 +- .../components/matrix_keypad/matrix_keypad.h | 4 ++-- esphome/components/max17043/automation.h | 2 +- esphome/components/max17043/max17043.h | 2 +- esphome/components/max31855/max31855.h | 8 ++++---- esphome/components/max31856/max31856.h | 8 ++++---- esphome/components/max31865/max31865.h | 8 ++++---- esphome/components/max44009/max44009.h | 2 +- esphome/components/max6675/max6675.h | 8 ++++---- esphome/components/max6956/automation.h | 4 ++-- esphome/components/max6956/max6956.h | 4 ++-- .../max6956/output/max6956_led_output.h | 2 +- esphome/components/max7219/max7219.h | 6 +++--- esphome/components/max7219digit/automation.h | 8 ++++---- .../components/max7219digit/max7219digit.h | 6 +++--- esphome/components/max9611/max9611.h | 2 +- esphome/components/mcp23008/mcp23008.h | 2 +- esphome/components/mcp23016/mcp23016.h | 4 ++-- esphome/components/mcp23017/mcp23017.h | 2 +- esphome/components/mcp23s08/mcp23s08.h | 6 +++--- esphome/components/mcp23s17/mcp23s17.h | 6 +++--- .../components/mcp23xxx_base/mcp23xxx_base.h | 2 +- esphome/components/mcp2515/mcp2515.h | 6 +++--- esphome/components/mcp3008/mcp3008.h | 8 ++++---- .../mcp3008/sensor/mcp3008_sensor.h | 8 ++++---- esphome/components/mcp3204/mcp3204.h | 6 +++--- .../mcp3204/sensor/mcp3204_sensor.h | 8 ++++---- esphome/components/mcp3221/mcp3221_sensor.h | 8 ++++---- esphome/components/mcp4461/mcp4461.h | 2 +- .../mcp4461/output/mcp4461_output.h | 2 +- esphome/components/mcp4725/mcp4725.h | 2 +- esphome/components/mcp4728/mcp4728.h | 2 +- .../mcp4728/output/mcp4728_output.h | 2 +- esphome/components/mcp47a1/mcp47a1.h | 2 +- esphome/components/mcp9600/mcp9600.h | 2 +- esphome/components/mcp9808/mcp9808.h | 2 +- esphome/components/media_player/automation.h | 20 +++++++++---------- esphome/components/mhz19/mhz19.h | 11 +++++----- .../micronova/button/micronova_button.h | 2 +- esphome/components/micronova/micronova.h | 2 +- .../micronova/number/micronova_number.h | 2 +- .../micronova/sensor/micronova_sensor.h | 2 +- .../micronova/switch/micronova_switch.h | 2 +- .../text_sensor/micronova_text_sensor.h | 2 +- 44 files changed, 101 insertions(+), 100 deletions(-) diff --git a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h index 53ae0b5c03b..000a9e5de33 100644 --- a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h +++ b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::matrix_keypad { -class MatrixKeypadBinarySensor : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { +class MatrixKeypadBinarySensor final : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { public: MatrixKeypadBinarySensor(uint8_t key) : has_key_(true), key_(key){}; MatrixKeypadBinarySensor(const char *key) : has_key_(true), key_((uint8_t) key[0]){}; diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 1e263842ea0..8c9acc8e0cb 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -18,9 +18,9 @@ class MatrixKeypadListener { virtual void key_released(uint8_t key){}; }; -class MatrixKeyTrigger : public Trigger {}; +class MatrixKeyTrigger final : public Trigger {}; -class MatrixKeypad : public key_provider::KeyProvider, public Component { +class MatrixKeypad final : public key_provider::KeyProvider, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h index c98516d2593..6b19e5bd5e9 100644 --- a/esphome/components/max17043/automation.h +++ b/esphome/components/max17043/automation.h @@ -5,7 +5,7 @@ namespace esphome::max17043 { -template class SleepAction : public Action { +template class SleepAction final : public Action { public: explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} diff --git a/esphome/components/max17043/max17043.h b/esphome/components/max17043/max17043.h index dd2e35df555..ffe4d916ba6 100644 --- a/esphome/components/max17043/max17043.h +++ b/esphome/components/max17043/max17043.h @@ -6,7 +6,7 @@ namespace esphome::max17043 { -class MAX17043Component : public PollingComponent, public i2c::I2CDevice { +class MAX17043Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31855/max31855.h b/esphome/components/max31855/max31855.h index dd7a2052686..527d26f99d2 100644 --- a/esphome/components/max31855/max31855.h +++ b/esphome/components/max31855/max31855.h @@ -8,10 +8,10 @@ namespace esphome::max31855 { -class MAX31855Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31855Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_sensor(sensor::Sensor *temperature_sensor) { temperature_reference_ = temperature_sensor; } diff --git a/esphome/components/max31856/max31856.h b/esphome/components/max31856/max31856.h index 0a983b72d98..83aa815aa08 100644 --- a/esphome/components/max31856/max31856.h +++ b/esphome/components/max31856/max31856.h @@ -68,10 +68,10 @@ enum MAX31856ConfigFilter { FILTER_50HZ = 1, }; -class MAX31856Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31856Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31865/max31865.h b/esphome/components/max31865/max31865.h index 3362cd30dee..27c107ba0b1 100644 --- a/esphome/components/max31865/max31865.h +++ b/esphome/components/max31865/max31865.h @@ -22,10 +22,10 @@ enum MAX31865ConfigFilter { FILTER_50HZ = 1, }; -class MAX31865Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31865Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_resistance(float reference_resistance) { reference_resistance_ = reference_resistance; } void set_nominal_resistance(float nominal_resistance) { rtd_nominal_resistance_ = nominal_resistance; } diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index 12fd0b1ce0f..b62aed7a567 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -9,7 +9,7 @@ namespace esphome::max44009 { enum MAX44009Mode { MAX44009_MODE_AUTO, MAX44009_MODE_LOW_POWER, MAX44009_MODE_CONTINUOUS }; /// This class implements support for the MAX44009 Illuminance i2c sensor. -class MAX44009Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: MAX44009Sensor() {} diff --git a/esphome/components/max6675/max6675.h b/esphome/components/max6675/max6675.h index e7b5c4dbde5..fc46c8c0471 100644 --- a/esphome/components/max6675/max6675.h +++ b/esphome/components/max6675/max6675.h @@ -6,10 +6,10 @@ namespace esphome::max6675 { -class MAX6675Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX6675Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h index 547ed5a8656..f1db2e32400 100644 --- a/esphome/components/max6956/automation.h +++ b/esphome/components/max6956/automation.h @@ -6,7 +6,7 @@ namespace esphome::max6956 { -template class SetCurrentGlobalAction : public Action { +template class SetCurrentGlobalAction final : public Action { public: SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} @@ -21,7 +21,7 @@ template class SetCurrentGlobalAction : public Action { MAX6956 *max6956_; }; -template class SetCurrentModeAction : public Action { +template class SetCurrentModeAction final : public Action { public: SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 83ccfab559f..4dbee165284 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -35,7 +35,7 @@ enum MAX6956GPIOFlag { FLAG_LED = 0x20 }; enum MAX6956CURRENTMODE { GLOBAL = 0x00, SEGMENT = 0x01 }; -class MAX6956 : public Component, public i2c::I2CDevice { +class MAX6956 final : public Component, public i2c::I2CDevice { public: MAX6956() = default; @@ -69,7 +69,7 @@ class MAX6956 : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin : public GPIOPin { +class MAX6956GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/max6956/output/max6956_led_output.h b/esphome/components/max6956/output/max6956_led_output.h index 49e5b9ef842..c40e41371d5 100644 --- a/esphome/components/max6956/output/max6956_led_output.h +++ b/esphome/components/max6956/output/max6956_led_output.h @@ -7,7 +7,7 @@ namespace esphome::max6956 { class MAX6956; -class MAX6956LedChannel : public output::FloatOutput, public Component { +class MAX6956LedChannel final : public output::FloatOutput, public Component { public: void set_parent(MAX6956 *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/max7219/max7219.h b/esphome/components/max7219/max7219.h index ef38628f288..3eb4b8e27f0 100644 --- a/esphome/components/max7219/max7219.h +++ b/esphome/components/max7219/max7219.h @@ -12,9 +12,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public PollingComponent, - public spi::SPIDevice { +class MAX7219Component final : public PollingComponent, + public spi::SPIDevice { public: explicit MAX7219Component(uint8_t num_chips); diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h index 485a34075ee..f06dfd5087a 100644 --- a/esphome/components/max7219digit/automation.h +++ b/esphome/components/max7219digit/automation.h @@ -7,7 +7,7 @@ namespace esphome::max7219digit { -template class DisplayInvertAction : public Action, public Parented { +template class DisplayInvertAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -17,7 +17,7 @@ template class DisplayInvertAction : public Action, publi } }; -template class DisplayVisibilityAction : public Action, public Parented { +template class DisplayVisibilityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -27,7 +27,7 @@ template class DisplayVisibilityAction : public Action, p } }; -template class DisplayReverseAction : public Action, public Parented { +template class DisplayReverseAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -37,7 +37,7 @@ template class DisplayReverseAction : public Action, publ } }; -template class DisplayIntensityAction : public Action, public Parented { +template class DisplayIntensityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) diff --git a/esphome/components/max7219digit/max7219digit.h b/esphome/components/max7219digit/max7219digit.h index bbf43059ddc..9e6db204444 100644 --- a/esphome/components/max7219digit/max7219digit.h +++ b/esphome/components/max7219digit/max7219digit.h @@ -24,9 +24,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public display::DisplayBuffer, - public spi::SPIDevice { +class MAX7219Component final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(max7219_writer_t &&writer) { this->writer_local_ = writer; }; diff --git a/esphome/components/max9611/max9611.h b/esphome/components/max9611/max9611.h index b6fb5d81273..54e6414c790 100644 --- a/esphome/components/max9611/max9611.h +++ b/esphome/components/max9611/max9611.h @@ -33,7 +33,7 @@ enum MAX9611RegisterMap { CONTROL_REGISTER_2_ADRR = 0x0B, }; -class MAX9611Component : public PollingComponent, public i2c::I2CDevice { +class MAX9611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp23008/mcp23008.h b/esphome/components/mcp23008/mcp23008.h index ae2f9e1f3c0..38bd9c1ac49 100644 --- a/esphome/components/mcp23008/mcp23008.h +++ b/esphome/components/mcp23008/mcp23008.h @@ -7,7 +7,7 @@ namespace esphome::mcp23008 { -class MCP23008 : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { +class MCP23008 final : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { public: MCP23008() = default; diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 4a936a5b02e..14c0c9a2fc4 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -24,7 +24,7 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { +class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: MCP23016() = default; @@ -56,7 +56,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin : public GPIOPin { +class MCP23016GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp23017/mcp23017.h b/esphome/components/mcp23017/mcp23017.h index 86b84f9ad80..c745322bdf7 100644 --- a/esphome/components/mcp23017/mcp23017.h +++ b/esphome/components/mcp23017/mcp23017.h @@ -7,7 +7,7 @@ namespace esphome::mcp23017 { -class MCP23017 : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { +class MCP23017 final : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { public: MCP23017() = default; diff --git a/esphome/components/mcp23s08/mcp23s08.h b/esphome/components/mcp23s08/mcp23s08.h index 441525469f7..270d1467e20 100644 --- a/esphome/components/mcp23s08/mcp23s08.h +++ b/esphome/components/mcp23s08/mcp23s08.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s08 { -class MCP23S08 : public mcp23x08_base::MCP23X08Base, - public spi::SPIDevice { +class MCP23S08 final : public mcp23x08_base::MCP23X08Base, + public spi::SPIDevice { public: MCP23S08() = default; diff --git a/esphome/components/mcp23s17/mcp23s17.h b/esphome/components/mcp23s17/mcp23s17.h index 0cc9321c887..5346b2c8e23 100644 --- a/esphome/components/mcp23s17/mcp23s17.h +++ b/esphome/components/mcp23s17/mcp23s17.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s17 { -class MCP23S17 : public mcp23x17_base::MCP23X17Base, - public spi::SPIDevice { +class MCP23S17 final : public mcp23x17_base::MCP23X17Base, + public spi::SPIDevice { public: MCP23S17() = default; diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 5904a1eef62..1c45b0f4afa 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -56,7 +56,7 @@ template class MCP23XXXBase : public Component, public gpio_expander: InternalGPIOPin *interrupt_pin_{nullptr}; }; -template class MCP23XXXGPIOPin : public GPIOPin { +template class MCP23XXXGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index b77d9a25828..960e1508000 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -51,9 +51,9 @@ enum STAT : uint8_t { STAT_RX0IF = (1 << 0), STAT_RX1IF = (1 << 1) }; static const uint8_t STAT_RXIF_MASK = STAT_RX0IF | STAT_RX1IF; static const uint8_t EFLG_ERRORMASK = EFLG_RX1OVR | EFLG_RX0OVR | EFLG_TXBO | EFLG_TXEP | EFLG_RXEP; -class MCP2515 : public canbus::Canbus, - public spi::SPIDevice { +class MCP2515 final : public canbus::Canbus, + public spi::SPIDevice { public: MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; diff --git a/esphome/components/mcp3008/mcp3008.h b/esphome/components/mcp3008/mcp3008.h index 1b1b50c7935..d45d587ae8a 100644 --- a/esphome/components/mcp3008/mcp3008.h +++ b/esphome/components/mcp3008/mcp3008.h @@ -6,10 +6,10 @@ namespace esphome::mcp3008 { -class MCP3008 : public Component, - public spi::SPIDevice { // Running at the slowest max speed supported by the - // mcp3008. 2.7v = 75ksps +class MCP3008 final : public Component, + public spi::SPIDevice { // Running at the slowest max speed supported by + // the mcp3008. 2.7v = 75ksps public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.h b/esphome/components/mcp3008/sensor/mcp3008_sensor.h index 9267f80ea83..d72d521f65f 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.h +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.h @@ -8,10 +8,10 @@ namespace esphome::mcp3008 { -class MCP3008Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class MCP3008Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 8ce592f3866..6b835b67df5 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -6,9 +6,9 @@ namespace esphome::mcp3204 { -class MCP3204 : public Component, - public spi::SPIDevice { +class MCP3204 final : public Component, + public spi::SPIDevice { public: MCP3204() = default; diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 5fe5f54d1b8..54835c232bc 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -9,10 +9,10 @@ namespace esphome::mcp3204 { -class MCP3204Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class MCP3204Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: MCP3204Sensor(uint8_t pin, bool differential_mode) : pin_(pin), differential_mode_(differential_mode) {} diff --git a/esphome/components/mcp3221/mcp3221_sensor.h b/esphome/components/mcp3221/mcp3221_sensor.h index deef14e14d8..38b62c609f4 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.h +++ b/esphome/components/mcp3221/mcp3221_sensor.h @@ -10,10 +10,10 @@ namespace esphome::mcp3221 { -class MCP3221Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public i2c::I2CDevice { +class MCP3221Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public i2c::I2CDevice { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void update() override; diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 3a76f855b86..a577a4b4824 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -57,7 +57,7 @@ enum class Mcp4461TerminalIdx : uint8_t { MCP4461_TERMINAL_0 = 0, MCP4461_TERMIN class Mcp4461Wiper; // Mcp4461Component -class Mcp4461Component : public Component, public i2c::I2CDevice { +class Mcp4461Component final : public Component, public i2c::I2CDevice { public: Mcp4461Component(bool disable_wiper_0, bool disable_wiper_1, bool disable_wiper_2, bool disable_wiper_3) : wiper_0_disabled_(disable_wiper_0), diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 73eadceb502..20d81d825a1 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4461 { -class Mcp4461Wiper : public output::FloatOutput, public Parented { +class Mcp4461Wiper final : public output::FloatOutput, public Parented { public: Mcp4461Wiper(Mcp4461Component *parent, Mcp4461WiperIdx wiper) : parent_(parent), wiper_(wiper) {} /// @brief Set level of wiper diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 1acefc3ee4f..4f1f128e52b 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -8,7 +8,7 @@ static const uint8_t MCP4725_ADDR = 0x60; static const uint8_t MCP4725_RES = 12; namespace esphome::mcp4725 { -class MCP4725 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp4728/mcp4728.h b/esphome/components/mcp4728/mcp4728.h index 13076b3c4cf..e7511e52371 100644 --- a/esphome/components/mcp4728/mcp4728.h +++ b/esphome/components/mcp4728/mcp4728.h @@ -38,7 +38,7 @@ struct DACInputData { class MCP4728Channel; /// MCP4728 float output component. -class MCP4728Component : public Component, public i2c::I2CDevice { +class MCP4728Component final : public Component, public i2c::I2CDevice { public: MCP4728Component(bool store_in_eeprom) : store_in_eeprom_(store_in_eeprom) {} diff --git a/esphome/components/mcp4728/output/mcp4728_output.h b/esphome/components/mcp4728/output/mcp4728_output.h index 3ea65ecc7b0..827ce1517d9 100644 --- a/esphome/components/mcp4728/output/mcp4728_output.h +++ b/esphome/components/mcp4728/output/mcp4728_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4728 { -class MCP4728Channel : public output::FloatOutput { +class MCP4728Channel final : public output::FloatOutput { public: MCP4728Channel(MCP4728Component *parent, MCP4728ChannelIdx channel, MCP4728Vref vref, MCP4728Gain gain, MCP4728PwrDown pwrdown) diff --git a/esphome/components/mcp47a1/mcp47a1.h b/esphome/components/mcp47a1/mcp47a1.h index da9794e5aa1..b72c1255744 100644 --- a/esphome/components/mcp47a1/mcp47a1.h +++ b/esphome/components/mcp47a1/mcp47a1.h @@ -6,7 +6,7 @@ namespace esphome::mcp47a1 { -class MCP47A1 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP47A1 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void dump_config() override; void write_state(float state) override; diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index b7c0c834abe..523c9ace2f7 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -17,7 +17,7 @@ enum MCP9600ThermocoupleType : uint8_t { MCP9600_THERMOCOUPLE_TYPE_R = 0b111, }; -class MCP9600Component : public PollingComponent, public i2c::I2CDevice { +class MCP9600Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp9808/mcp9808.h b/esphome/components/mcp9808/mcp9808.h index 89530d9ed0f..b4ae51bf6f8 100644 --- a/esphome/components/mcp9808/mcp9808.h +++ b/esphome/components/mcp9808/mcp9808.h @@ -6,7 +6,7 @@ namespace esphome::mcp9808 { -class MCP9808Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MCP9808Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 9319335872a..899acfefdfb 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -6,7 +6,7 @@ namespace esphome::media_player { template -class MediaPlayerCommandAction : public Action, public Parented { +class MediaPlayerCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, announcement); void play(const Ts &...x) override { @@ -54,7 +54,7 @@ template using ClearPlaylistAction = MediaPlayerCommandAction; template -class MediaPlayerMediaAction : public Action, public Parented { +class MediaPlayerMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, media_url) TEMPLATABLE_VALUE(bool, announcement) void play(const Ts &...x) override { @@ -70,7 +70,7 @@ using PlayMediaAction = MediaPlayerMediaAction using EnqueueMediaAction = MediaPlayerMediaAction; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } }; @@ -97,39 +97,39 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition : public Condition, public Parented { +template class IsIdleCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } }; -template class IsPausedCondition : public Condition, public Parented { +template class IsPausedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } }; -template class IsAnnouncingCondition : public Condition, public Parented { +template class IsAnnouncingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; } }; -template class IsOnCondition : public Condition, public Parented { +template class IsOnCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } }; -template class IsOffCondition : public Condition, public Parented { +template class IsOffCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_muted(); } }; diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index e577b985373..3cef3a3930d 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -20,7 +20,7 @@ enum MHZ19DetectionRange { MHZ19_DETECTION_RANGE_0_10000PPM, }; -class MHZ19Component : public PollingComponent, public uart::UARTDevice { +class MHZ19Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -49,22 +49,23 @@ class MHZ19Component : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction : public Action, public Parented { +template class MHZ19CalibrateZeroAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_zero(); } }; -template class MHZ19ABCEnableAction : public Action, public Parented { +template class MHZ19ABCEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_enable(); } }; -template class MHZ19ABCDisableAction : public Action, public Parented { +template class MHZ19ABCDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_disable(); } }; -template class MHZ19DetectionRangeSetAction : public Action, public Parented { +template +class MHZ19DetectionRangeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 0258dbb53c2..9f8f66ee02d 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { +class MicroNovaButton final : public Component, public button::Button, public MicroNovaBaseListener { public: MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 58cca30b836..c57286db6c1 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -58,7 +58,7 @@ class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent ///////////////////////////////////////////////////////////////////// // Main component class -class MicroNova : public Component, public uart::UARTDevice { +class MicroNova final : public Component, public uart::UARTDevice { public: MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 73666b632bf..91e4253b46c 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaNumber : public number::Number, public MicroNovaListener { +class MicroNovaNumber final : public number::Number, public MicroNovaListener { public: MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index f3b06d140e4..8263ad09481 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { +class MicroNovaSensor final : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index fee3c739769..4a4d5eb721d 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { +class MicroNovaSwitch final : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 6918a372e86..2de93404a5d 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -17,7 +17,7 @@ static const char *const STOVE_STATES[11] = {"Off", "No ignition alarm", "Undefined alarm"}; -class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { +class MicroNovaTextSensor final : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; From 9f5ed6fdfd3d4fc68c4444c94bac71522e8c4c19 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:45 +1200 Subject: [PATCH 108/343] Mark configurable classes as final (6/21) (#16957) --- .../esp32_ble_client/ble_characteristic.h | 2 +- .../components/esp32_ble_client/ble_service.h | 2 +- .../esp32_ble_server/ble_characteristic.h | 2 +- .../components/esp32_ble_server/ble_server.h | 2 +- .../esp32_ble_server/ble_server_automations.h | 6 ++--- .../components/esp32_ble_server/ble_service.h | 2 +- .../components/esp32_ble_tracker/automation.h | 12 ++++----- .../esp32_ble_tracker/esp32_ble_tracker.h | 6 ++--- .../components/esp32_camera/esp32_camera.h | 8 +++--- .../camera_web_server.h | 2 +- esphome/components/esp32_can/esp32_can.h | 2 +- esphome/components/esp32_dac/esp32_dac.h | 2 +- .../esp32_hosted/update/esp32_hosted_update.h | 2 +- esphome/components/esp32_improv/automation.h | 10 +++---- .../esp32_improv/esp32_improv_component.h | 2 +- .../esp32_rmt_led_strip/led_strip.h | 2 +- esphome/components/esp32_touch/esp32_touch.h | 2 +- esphome/components/esp8266/gpio.h | 2 +- esphome/components/esp8266_pwm/esp8266_pwm.h | 4 +-- esphome/components/esp_ldo/esp_ldo.h | 4 +-- esphome/components/espnow/automation.h | 20 +++++++------- esphome/components/espnow/espnow_component.h | 2 +- .../packet_transport/espnow_transport.h | 8 +++--- esphome/components/ethernet/automation.h | 8 +++--- esphome/components/event/automation.h | 4 +-- .../exposure_notifications.h | 4 +-- esphome/components/ezo/ezo.h | 2 +- esphome/components/ezo_pmp/ezo_pmp.h | 26 +++++++++---------- .../button/factory_reset_button.h | 2 +- .../components/factory_reset/factory_reset.h | 2 +- .../switch/factory_reset_switch.h | 2 +- esphome/components/fan/automation.h | 26 +++++++++---------- .../components/fastled_base/fastled_light.h | 2 +- esphome/components/feedback/feedback_cover.h | 2 +- .../fingerprint_grow/fingerprint_grow.h | 17 +++++++----- esphome/components/font/font.h | 4 +-- esphome/components/fs3000/fs3000.h | 2 +- .../ft5x06/touchscreen/ft5x06_touchscreen.h | 2 +- esphome/components/ft63x6/ft63x6.h | 2 +- .../fujitsu_general/fujitsu_general.h | 2 +- 40 files changed, 109 insertions(+), 106 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index 1428b427391..7834d99c9bd 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: ~BLECharacteristic(); bool parsed = false; diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 00ecc777e79..bb1fd2b9fa0 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEClientBase; -class BLEService { +class BLEService final { public: ~BLEService(); bool parsed = false; diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 933177a399d..b7a3fae1a5b 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -24,7 +24,7 @@ using namespace bytebuffer; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: BLECharacteristic(ESPBTUUID uuid, uint32_t properties); ~BLECharacteristic(); diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 9ba108499e0..fdd92812cd4 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -23,7 +23,7 @@ namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -class BLEServer : public Component, public Parented { +class BLEServer final : public Component, public Parented { public: void setup() override; void loop() override; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index b4e9ed004ec..c6cba14b9b3 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -64,7 +64,7 @@ class BLECharacteristicSetValueActionManager { void remove_listener_(BLECharacteristic *characteristic); }; -template class BLECharacteristicSetValueAction : public Action { +template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} TEMPLATABLE_VALUE(std::vector, buffer) @@ -92,7 +92,7 @@ template class BLECharacteristicSetValueAction : public Action class BLECharacteristicNotifyAction : public Action { +template class BLECharacteristicNotifyAction final : public Action { public: BLECharacteristicNotifyAction(BLECharacteristic *characteristic) : parent_(characteristic) {} void play(const Ts &...x) override { @@ -110,7 +110,7 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { +template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} TEMPLATABLE_VALUE(std::vector, buffer) diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index 03fa8093acb..a0592d0a809 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -19,7 +19,7 @@ class BLEServer; using namespace esp32_ble; -class BLEService { +class BLEService final { public: BLEService(ESPBTUUID uuid, uint16_t num_handles, uint8_t inst_id, bool advertise); ~BLEService(); diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 6d26040ccb7..b653325f56e 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -7,7 +7,7 @@ namespace esphome::esp32_ble_tracker { #ifdef USE_ESP32_BLE_DEVICE -class ESPBTAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } @@ -28,7 +28,7 @@ class ESPBTAdvertiseTrigger : public Trigger, public ESPBTD std::vector address_vec_; }; -class BLEServiceDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEServiceDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -54,7 +54,7 @@ class BLEServiceDataAdvertiseTrigger : public Trigger, publi ESPBTUUID uuid_; }; -class BLEManufacturerDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEManufacturerDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -82,7 +82,7 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, #endif // USE_ESP32_BLE_DEVICE -class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } @@ -92,7 +92,7 @@ class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction : public Action { +template class ESP32BLEStartScanAction final : public Action { public: ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} TEMPLATABLE_VALUE(bool, continuous) @@ -111,7 +111,7 @@ template class ESP32BLEStartScanAction : public Action { ESP32BLETracker *parent_; }; -template class ESP32BLEStopScanAction : public Action, public Parented { +template class ESP32BLEStopScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_scan(); } }; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 78ff60f3741..3415196a117 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -294,11 +294,11 @@ class ESPBTClient : public ESPBTDeviceListener { uint8_t *tracker_state_version_{nullptr}; }; -class ESP32BLETracker : public Component, +class ESP32BLETracker final : public Component, #ifdef USE_OTA_STATE_LISTENER - public ota::OTAGlobalStateListener, + public ota::OTAGlobalStateListener, #endif - public Parented { + public Parented { public: void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 7d020b5caf7..83dab5f77a3 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -119,7 +119,7 @@ class ESP32CameraImageReader : public camera::CameraImageReader { }; /* ---------------- ESP32Camera class ---------------- */ -class ESP32Camera : public camera::Camera { +class ESP32Camera final : public camera::Camera { public: ESP32Camera(); @@ -235,7 +235,7 @@ class ESP32Camera : public camera::Camera { RAMAllocator fb_allocator_{RAMAllocator::ALLOC_INTERNAL}; }; -class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { +class ESP32CameraImageTrigger final : public Trigger, public camera::CameraListener { public: explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_camera_image(const std::shared_ptr &image) override { @@ -246,13 +246,13 @@ class ESP32CameraImageTrigger : public Trigger, public camera:: } }; -class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStartTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStopTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_stop() override { this->trigger(); } diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index 568dc68c460..76f8317248f 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -17,7 +17,7 @@ namespace esphome::esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component, public camera::CameraListener { +class CameraWebServer final : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index 2e10d254e6e..f224e10be33 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -14,7 +14,7 @@ enum CanMode : uint8_t { CAN_MODE_LISTEN_ONLY = 1, }; -class ESP32Can : public canbus::Canbus { +class ESP32Can final : public canbus::Canbus { public: void set_rx(int rx) { rx_ = rx; } void set_tx(int tx) { tx_ = tx; } diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 108b96cd399..ee6b506211f 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -11,7 +11,7 @@ namespace esphome::esp32_dac { -class ESP32DAC : public output::FloatOutput, public Component { +class ESP32DAC final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 005e6a6f211..4f9d04738dd 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -13,7 +13,7 @@ namespace esphome::esp32_hosted { -class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { +class Esp32HostedUpdate final : public update::UpdateEntity, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/esp32_improv/automation.h index 19e1b6e7e39..b3b61f47785 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/esp32_improv/automation.h @@ -9,7 +9,7 @@ namespace esphome::esp32_improv { -class ESP32ImprovProvisionedTrigger : public Trigger<> { +class ESP32ImprovProvisionedTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -23,7 +23,7 @@ class ESP32ImprovProvisionedTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovProvisioningTrigger : public Trigger<> { +class ESP32ImprovProvisioningTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -37,7 +37,7 @@ class ESP32ImprovProvisioningTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStartTrigger : public Trigger<> { +class ESP32ImprovStartTrigger final : public Trigger<> { public: explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -52,7 +52,7 @@ class ESP32ImprovStartTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStateTrigger : public Trigger { +class ESP32ImprovStateTrigger final : public Trigger { public: explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -66,7 +66,7 @@ class ESP32ImprovStateTrigger : public Trigger { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStoppedTrigger : public Trigger<> { +class ESP32ImprovStoppedTrigger final : public Trigger<> { public: explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 400006cfb32..d948dba3b3b 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -32,7 +32,7 @@ namespace esphome::esp32_improv { using namespace esp32_ble_server; -class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { +class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { public: ESP32ImprovComponent(); void dump_config() override; diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 8fb6b63afed..d7ba2aafbf8 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -30,7 +30,7 @@ struct LedParams { rmt_symbol_word_t reset; }; -class ESP32RMTLEDStripLightOutput : public light::AddressableLight { +class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/esp32_touch/esp32_touch.h b/esphome/components/esp32_touch/esp32_touch.h index d51b2d49225..55ac5c5b63a 100644 --- a/esphome/components/esp32_touch/esp32_touch.h +++ b/esphome/components/esp32_touch/esp32_touch.h @@ -172,7 +172,7 @@ class ESP32TouchComponent final : public Component { }; /// Simple helper class to expose a touch pad value as a binary sensor. -class ESP32TouchBinarySensor : public binary_sensor::BinarySensor { +class ESP32TouchBinarySensor final : public binary_sensor::BinarySensor { public: ESP32TouchBinarySensor(int channel_id, uint32_t threshold, uint32_t wakeup_threshold) : channel_id_(channel_id), threshold_(threshold), wakeup_threshold_(wakeup_threshold) {} diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index ff149abfbe7..57ef06106a2 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -7,7 +7,7 @@ namespace esphome::esp8266 { -class ESP8266GPIOPin : public InternalGPIOPin { +class ESP8266GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 51c4ea16028..be58a098b6e 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -9,7 +9,7 @@ namespace esphome::esp8266_pwm { -class ESP8266PWM : public output::FloatOutput, public Component { +class ESP8266PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -34,7 +34,7 @@ class ESP8266PWM : public output::FloatOutput, public Component { float last_output_{0.0}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index bb1579e83dd..0451c338dd1 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -6,7 +6,7 @@ namespace esphome::esp_ldo { -class EspLdo : public Component { +class EspLdo final : public Component { public: EspLdo(int channel) : channel_(channel) {} @@ -27,7 +27,7 @@ class EspLdo : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction : public Action { +template class AdjustAction final : public Action { public: explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 9c3c55e4eff..5e995aff533 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -9,7 +9,7 @@ namespace esphome::espnow { -template class SendAction : public Action, public Parented { +template class SendAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); TEMPLATABLE_VALUE(std::vector, data); @@ -86,7 +86,7 @@ template class SendAction : public Action, public Parente } flags_{0}; }; -template class AddPeerAction : public Action, public Parented { +template class AddPeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -96,7 +96,7 @@ template class AddPeerAction : public Action, public Pare } }; -template class DeletePeerAction : public Action, public Parented { +template class DeletePeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -106,7 +106,7 @@ template class DeletePeerAction : public Action, public P } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, channel) protected: @@ -119,8 +119,8 @@ template class SetChannelAction : public Action, public P } }; -class OnReceiveTrigger : public Trigger, - public ESPNowReceivedPacketHandler { +class OnReceiveTrigger final : public Trigger, + public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); @@ -141,16 +141,16 @@ class OnReceiveTrigger : public Trigger, - public ESPNowUnknownPeerHandler { +class OnUnknownPeerTrigger final : public Trigger, + public ESPNowUnknownPeerHandler { public: bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger : public Trigger, - public ESPNowBroadcastHandler { +class OnBroadcastTrigger final : public Trigger, + public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ff9581ec2ff..eacc3eb886d 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -88,7 +88,7 @@ class ESPNowBroadcastHandler { virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; }; -class ESPNowComponent : public Component { +class ESPNowComponent final : public Component { public: ESPNowComponent(); void setup() override; diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 5916a7fa5f0..7e1d08618b5 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -11,10 +11,10 @@ namespace esphome::espnow { -class ESPNowTransport : public packet_transport::PacketTransport, - public Parented, - public ESPNowReceivedPacketHandler, - public ESPNowBroadcastHandler { +class ESPNowTransport final : public packet_transport::PacketTransport, + public Parented, + public ESPNowReceivedPacketHandler, + public ESPNowBroadcastHandler { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h index c16abc5bda8..f975f52bffd 100644 --- a/esphome/components/ethernet/automation.h +++ b/esphome/components/ethernet/automation.h @@ -6,22 +6,22 @@ namespace esphome::ethernet { -template class EthernetConnectedCondition : public Condition { +template class EthernetConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_connected(); } }; -template class EthernetEnabledCondition : public Condition { +template class EthernetEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } }; -template class EthernetEnableAction : public Action { +template class EthernetEnableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->enable(); } }; -template class EthernetDisableAction : public Action { +template class EthernetDisableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->disable(); } }; diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 3444a7b1bb7..73a6336f780 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -6,14 +6,14 @@ namespace esphome::event { -template class TriggerEventAction : public Action, public Parented { +template class TriggerEventAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, event_type) void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } }; -class EventTrigger : public Trigger { +class EventTrigger final : public Trigger { public: EventTrigger(Event *event) { event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 80184f9cfd4..6a703a9a921 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -16,8 +16,8 @@ struct ExposureNotification { std::array associated_encrypted_metadata; }; -class ExposureNotificationTrigger : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { +class ExposureNotificationTrigger final : public Trigger, + public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index aea276e0011..a20419eceb1 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -32,7 +32,7 @@ class EzoCommand { }; /// This class implements support for the EZO circuits in i2c mode -class EZOSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class EZOSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void loop() override; void dump_config() override; diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 8a6da5fe744..55283f2d097 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -19,7 +19,7 @@ namespace esphome::ezo_pmp { -class EzoPMP : public PollingComponent, public i2c::I2CDevice { +class EzoPMP final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; @@ -114,7 +114,7 @@ class EzoPMP : public PollingComponent, public i2c::I2CDevice { }; // Action Templates -template class EzoPMPFindAction : public Action { +template class EzoPMPFindAction final : public Action { public: EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -124,7 +124,7 @@ template class EzoPMPFindAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseContinuouslyAction : public Action { +template class EzoPMPDoseContinuouslyAction final : public Action { public: EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -134,7 +134,7 @@ template class EzoPMPDoseContinuouslyAction : public Action class EzoPMPDoseVolumeAction : public Action { +template class EzoPMPDoseVolumeAction final : public Action { public: EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -145,7 +145,7 @@ template class EzoPMPDoseVolumeAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseVolumeOverTimeAction : public Action { +template class EzoPMPDoseVolumeOverTimeAction final : public Action { public: EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -159,7 +159,7 @@ template class EzoPMPDoseVolumeOverTimeAction : public Action class EzoPMPDoseWithConstantFlowRateAction : public Action { +template class EzoPMPDoseWithConstantFlowRateAction final : public Action { public: EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -173,7 +173,7 @@ template class EzoPMPDoseWithConstantFlowRateAction : public Act EzoPMP *ezopmp_; }; -template class EzoPMPSetCalibrationVolumeAction : public Action { +template class EzoPMPSetCalibrationVolumeAction final : public Action { public: EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -184,7 +184,7 @@ template class EzoPMPSetCalibrationVolumeAction : public Action< EzoPMP *ezopmp_; }; -template class EzoPMPClearTotalVolumeDispensedAction : public Action { +template class EzoPMPClearTotalVolumeDispensedAction final : public Action { public: EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -194,7 +194,7 @@ template class EzoPMPClearTotalVolumeDispensedAction : public Ac EzoPMP *ezopmp_; }; -template class EzoPMPClearCalibrationAction : public Action { +template class EzoPMPClearCalibrationAction final : public Action { public: EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -204,7 +204,7 @@ template class EzoPMPClearCalibrationAction : public Action class EzoPMPPauseDosingAction : public Action { +template class EzoPMPPauseDosingAction final : public Action { public: EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -214,7 +214,7 @@ template class EzoPMPPauseDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPStopDosingAction : public Action { +template class EzoPMPStopDosingAction final : public Action { public: EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -224,7 +224,7 @@ template class EzoPMPStopDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPChangeI2CAddressAction : public Action { +template class EzoPMPChangeI2CAddressAction final : public Action { public: EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -235,7 +235,7 @@ template class EzoPMPChangeI2CAddressAction : public Action class EzoPMPArbitraryCommandAction : public Action { +template class EzoPMPArbitraryCommandAction final : public Action { public: EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index a8cb8976141..0bb8a62f5eb 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::factory_reset { -class FactoryResetButton : public button::Button, public Component { +class FactoryResetButton final : public button::Button, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 41ee627c4b3..d80d2d2406c 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -10,7 +10,7 @@ #endif namespace esphome::factory_reset { -class FactoryResetComponent : public Component { +class FactoryResetComponent final : public Component { public: FactoryResetComponent(uint8_t required_count, uint16_t max_interval) : max_interval_(max_interval), required_count_(required_count) {} diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index be80356b311..fb76b10cf3f 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -6,7 +6,7 @@ namespace esphome::factory_reset { -class FactoryResetSwitch : public switch_::Switch, public Component { +class FactoryResetSwitch final : public switch_::Switch, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 964ebe77a07..cbd994e749a 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -18,7 +18,7 @@ namespace esphome::fan { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} @@ -33,7 +33,7 @@ template class TurnOnAction : public Action { ApplyFn apply_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Fan *state) : state_(state) {} @@ -42,7 +42,7 @@ template class TurnOffAction : public Action { Fan *state_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Fan *state) : state_(state) {} @@ -51,7 +51,7 @@ template class ToggleAction : public Action { Fan *state_; }; -template class CycleSpeedAction : public Action { +template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} @@ -95,7 +95,7 @@ template class CycleSpeedAction : public Action { Fan *state_; }; -template class FanIsOnCondition : public Condition { +template class FanIsOnCondition final : public Condition { public: explicit FanIsOnCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->state; } @@ -103,7 +103,7 @@ template class FanIsOnCondition : public Condition { protected: Fan *state_; }; -template class FanIsOffCondition : public Condition { +template class FanIsOffCondition final : public Condition { public: explicit FanIsOffCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->state; } @@ -112,7 +112,7 @@ template class FanIsOffCondition : public Condition { Fan *state_; }; -class FanStateTrigger : public Trigger { +class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { this->trigger(this->fan_); }); @@ -122,7 +122,7 @@ class FanStateTrigger : public Trigger { Fan *fan_; }; -class FanTurnOnTrigger : public Trigger<> { +class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -141,7 +141,7 @@ class FanTurnOnTrigger : public Trigger<> { bool last_on_; }; -class FanTurnOffTrigger : public Trigger<> { +class FanTurnOffTrigger final : public Trigger<> { public: FanTurnOffTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -160,7 +160,7 @@ class FanTurnOffTrigger : public Trigger<> { bool last_on_; }; -class FanDirectionSetTrigger : public Trigger { +class FanDirectionSetTrigger final : public Trigger { public: FanDirectionSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -179,7 +179,7 @@ class FanDirectionSetTrigger : public Trigger { FanDirection last_direction_; }; -class FanOscillatingSetTrigger : public Trigger { +class FanOscillatingSetTrigger final : public Trigger { public: FanOscillatingSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -198,7 +198,7 @@ class FanOscillatingSetTrigger : public Trigger { bool last_oscillating_; }; -class FanSpeedSetTrigger : public Trigger { +class FanSpeedSetTrigger final : public Trigger { public: FanSpeedSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -217,7 +217,7 @@ class FanSpeedSetTrigger : public Trigger { int last_speed_; }; -class FanPresetSetTrigger : public Trigger { +class FanPresetSetTrigger final : public Trigger { public: FanPresetSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index f8535eb6286..1261b742a10 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -17,7 +17,7 @@ namespace esphome::fastled_base { -class FastLEDLightOutput : public light::AddressableLight { +class FastLEDLightOutput final : public light::AddressableLight { public: /// Only for custom effects: Get the internal controller. CLEDController *get_controller() const { return this->controller_; } diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index ed6f7490f8b..3e4600acd2f 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -10,7 +10,7 @@ namespace esphome::feedback { -class FeedbackCover : public cover::Cover, public Component { +class FeedbackCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 7cecb7dc82f..67662192ee4 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -92,7 +92,7 @@ enum GrowAuraLEDColor { WHITE = 0x07, }; -class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevice { +class FingerprintGrowComponent final : public PollingComponent, public uart::UARTDevice { public: void update() override; void setup() override; @@ -209,7 +209,8 @@ class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevic CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template +class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) TEMPLATABLE_VALUE(uint8_t, num_scans) @@ -226,12 +227,12 @@ template class EnrollmentAction : public Action, public P }; template -class CancelEnrollmentAction : public Action, public Parented { +class CancelEnrollmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) @@ -241,12 +242,13 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } }; -template class LEDControlAction : public Action, public Parented { +template +class LEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -256,7 +258,8 @@ template class LEDControlAction : public Action, public P } }; -template class AuraLEDControlAction : public Action, public Parented { +template +class AuraLEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) TEMPLATABLE_VALUE(uint8_t, speed) diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 9c9cfa0f6d0..fa24181bd00 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -14,7 +14,7 @@ namespace esphome::font { class Font; -class Glyph { +class Glyph final { public: constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, int height) @@ -37,7 +37,7 @@ class Glyph { int height; }; -class Font +class Font final #ifdef USE_DISPLAY : public display::BaseFont #endif diff --git a/esphome/components/fs3000/fs3000.h b/esphome/components/fs3000/fs3000.h index c019b1366bc..e98e72fa8ea 100644 --- a/esphome/components/fs3000/fs3000.h +++ b/esphome/components/fs3000/fs3000.h @@ -11,7 +11,7 @@ namespace esphome::fs3000 { // 1015 has a max speed detection of 15 m/s enum FS3000Model { FIVE, FIFTEEN }; -class FS3000Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class FS3000Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void update() override; diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h index 7cf8769f7ac..d788b2044cf 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h @@ -34,7 +34,7 @@ enum FTMode : uint8_t { static const size_t MAX_TOUCHES = 5; // max number of possible touches reported -class FT5x06Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class FT5x06Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ft63x6/ft63x6.h b/esphome/components/ft63x6/ft63x6.h index efa03168d97..5e72cf77d89 100644 --- a/esphome/components/ft63x6/ft63x6.h +++ b/esphome/components/ft63x6/ft63x6.h @@ -17,7 +17,7 @@ using namespace touchscreen; static const uint8_t FT6X36_DEFAULT_THRESHOLD = 22; -class FT63X6Touchscreen : public Touchscreen, public i2c::I2CDevice { +class FT63X6Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index ca93e4b3002..8d2ec883da9 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -46,7 +46,7 @@ const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius */ // clang-format on -class FujitsuGeneralClimate : public climate_ir::ClimateIR { +class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() : ClimateIR(FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX, 1.0f, true, true, From c69cfd44be1676eb3898b1ea86a983deda96ea5d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:04:55 +1200 Subject: [PATCH 109/343] Mark configurable classes as final (5/21) (#16956) --- .../components/dfrobot_sen0395/automation.h | 4 +-- .../dfrobot_sen0395/dfrobot_sen0395.h | 2 +- .../switch/dfrobot_sen0395_switch.h | 8 +++--- esphome/components/dht/dht.h | 2 +- esphome/components/dht12/dht12.h | 2 +- esphome/components/display/display.h | 12 ++++---- .../components/display_menu_base/automation.h | 28 +++++++++---------- .../components/display_menu_base/menu_item.h | 12 ++++---- esphome/components/dlms_meter/dlms_meter.h | 2 +- esphome/components/dps310/dps310.h | 2 +- esphome/components/ds1307/ds1307.h | 6 ++-- esphome/components/ds2484/ds2484.h | 2 +- esphome/components/dsmr/dsmr.h | 2 +- .../components/duty_cycle/duty_cycle_sensor.h | 2 +- .../components/duty_time/duty_time_sensor.h | 4 +-- esphome/components/e131/e131.h | 2 +- esphome/components/ee895/ee895.h | 2 +- .../ektf2232/touchscreen/ektf2232.h | 2 +- esphome/components/emc2101/emc2101.h | 2 +- .../emc2101/output/emc2101_output.h | 2 +- .../emc2101/sensor/emc2101_sensor.h | 2 +- esphome/components/emmeti/emmeti.h | 2 +- esphome/components/emontx/emontx.h | 4 +-- .../components/emontx/sensor/emontx_sensor.h | 2 +- esphome/components/endstop/endstop_cover.h | 2 +- esphome/components/ens160_i2c/ens160_i2c.h | 2 +- esphome/components/ens160_spi/ens160_spi.h | 6 ++-- esphome/components/ens210/ens210.h | 2 +- esphome/components/es7210/es7210.h | 2 +- esphome/components/es7243e/es7243e.h | 2 +- esphome/components/es8156/es8156.h | 2 +- esphome/components/es8311/es8311.h | 2 +- esphome/components/es8388/es8388.h | 2 +- .../es8388/select/adc_input_mic_select.h | 2 +- .../es8388/select/dac_output_select.h | 2 +- esphome/components/esp32/gpio.h | 2 +- esphome/components/esp32_ble/ble.h | 8 +++--- .../esp32_ble_beacon/esp32_ble_beacon.h | 2 +- 38 files changed, 74 insertions(+), 74 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index bd91381d474..a5f4c99014d 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -8,13 +8,13 @@ namespace esphome::dfrobot_sen0395 { template -class DfrobotSen0395ResetAction : public Action, public Parented { +class DfrobotSen0395ResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } }; template -class DfrobotSen0395SettingsAction : public Action, public Parented { +class DfrobotSen0395SettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, factory_reset) TEMPLATABLE_VALUE(int8_t, start_after_power_on) diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 03e3b6b6ec1..448a18a4775 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -36,7 +36,7 @@ class CircularCommandQueue { std::unique_ptr commands_[COMMAND_QUEUE_SIZE]; }; -class DfrobotSen0395Component : public uart::UARTDevice, public Component { +class DfrobotSen0395Component final : public uart::UARTDevice, public Component { #ifdef USE_SWITCH SUB_SWITCH(sensor_active) SUB_SWITCH(turn_on_led) diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h index d83734b0344..1c2e929b241 100644 --- a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h +++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h @@ -9,22 +9,22 @@ namespace esphome::dfrobot_sen0395 { class DfrobotSen0395Switch : public switch_::Switch, public Component, public Parented {}; -class Sen0395PowerSwitch : public DfrobotSen0395Switch { +class Sen0395PowerSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395LedSwitch : public DfrobotSen0395Switch { +class Sen0395LedSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395UartPresenceSwitch : public DfrobotSen0395Switch { +class Sen0395UartPresenceSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395StartAfterBootSwitch : public DfrobotSen0395Switch { +class Sen0395StartAfterBootSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 0c535f7cf6e..86292e144f9 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -18,7 +18,7 @@ enum DHTModel : uint8_t { }; /// Component for reading temperature/humidity measurements from DHT11/DHT22 sensors. -class DHT : public PollingComponent { +class DHT final : public PollingComponent { public: /** Manually select the DHT model. * diff --git a/esphome/components/dht12/dht12.h b/esphome/components/dht12/dht12.h index 5f4f822e70f..b835ac16489 100644 --- a/esphome/components/dht12/dht12.h +++ b/esphome/components/dht12/dht12.h @@ -6,7 +6,7 @@ namespace esphome::dht12 { -class DHT12Component : public PollingComponent, public i2c::I2CDevice { +class DHT12Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 6d0b7acfe88..3a136937f6a 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -796,7 +796,7 @@ class Display : public PollingComponent { bool show_test_card_{false}; }; -class DisplayPage { +class DisplayPage final { public: DisplayPage(display_writer_t writer); void show(); @@ -814,7 +814,7 @@ class DisplayPage { DisplayPage *next_{nullptr}; }; -template class DisplayPageShowAction : public Action { +template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) @@ -826,7 +826,7 @@ template class DisplayPageShowAction : public Action { } }; -template class DisplayPageShowNextAction : public Action { +template class DisplayPageShowNextAction final : public Action { public: DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} @@ -835,7 +835,7 @@ template class DisplayPageShowNextAction : public Action Display *buffer_; }; -template class DisplayPageShowPrevAction : public Action { +template class DisplayPageShowPrevAction final : public Action { public: DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} @@ -844,7 +844,7 @@ template class DisplayPageShowPrevAction : public Action Display *buffer_; }; -template class DisplayIsDisplayingPageCondition : public Condition { +template class DisplayIsDisplayingPageCondition final : public Condition { public: DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} @@ -856,7 +856,7 @@ template class DisplayIsDisplayingPageCondition : public Conditi DisplayPage *page_; }; -class DisplayOnPageChangeTrigger : public Trigger { +class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } void process(DisplayPage *from, DisplayPage *to); diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index d4f83055d19..be0044ffa4d 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,7 +5,7 @@ namespace esphome::display_menu_base { -template class UpAction : public Action { +template class UpAction final : public Action { public: explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -15,7 +15,7 @@ template class UpAction : public Action { DisplayMenuComponent *menu_; }; -template class DownAction : public Action { +template class DownAction final : public Action { public: explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -25,7 +25,7 @@ template class DownAction : public Action { DisplayMenuComponent *menu_; }; -template class LeftAction : public Action { +template class LeftAction final : public Action { public: explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -35,7 +35,7 @@ template class LeftAction : public Action { DisplayMenuComponent *menu_; }; -template class RightAction : public Action { +template class RightAction final : public Action { public: explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -45,7 +45,7 @@ template class RightAction : public Action { DisplayMenuComponent *menu_; }; -template class EnterAction : public Action { +template class EnterAction final : public Action { public: explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -55,7 +55,7 @@ template class EnterAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowAction : public Action { +template class ShowAction final : public Action { public: explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -65,7 +65,7 @@ template class ShowAction : public Action { DisplayMenuComponent *menu_; }; -template class HideAction : public Action { +template class HideAction final : public Action { public: explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -75,7 +75,7 @@ template class HideAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowMainAction : public Action { +template class ShowMainAction final : public Action { public: explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -84,7 +84,7 @@ template class ShowMainAction : public Action { protected: DisplayMenuComponent *menu_; }; -template class IsActiveCondition : public Condition { +template class IsActiveCondition final : public Condition { public: explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} bool check(const Ts &...x) override { return this->menu_->is_active(); } @@ -93,7 +93,7 @@ template class IsActiveCondition : public Condition { DisplayMenuComponent *menu_; }; -class DisplayMenuOnEnterTrigger : public Trigger { +class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); @@ -103,7 +103,7 @@ class DisplayMenuOnEnterTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnLeaveTrigger : public Trigger { +class DisplayMenuOnLeaveTrigger final : public Trigger { public: explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); @@ -113,7 +113,7 @@ class DisplayMenuOnLeaveTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnValueTrigger : public Trigger { +class DisplayMenuOnValueTrigger final : public Trigger { public: explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); @@ -123,7 +123,7 @@ class DisplayMenuOnValueTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnNextTrigger : public Trigger { +class DisplayMenuOnNextTrigger final : public Trigger { public: explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); @@ -133,7 +133,7 @@ class DisplayMenuOnNextTrigger : public Trigger { MenuItemCustom *parent_; }; -class DisplayMenuOnPrevTrigger : public Trigger { +class DisplayMenuOnPrevTrigger final : public Trigger { public: explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h index f3c41583f73..d5732377e30 100644 --- a/esphome/components/display_menu_base/menu_item.h +++ b/esphome/components/display_menu_base/menu_item.h @@ -70,7 +70,7 @@ class MenuItem { CallbackManager on_value_callbacks_{}; }; -class MenuItemMenu : public MenuItem { +class MenuItemMenu final : public MenuItem { public: explicit MenuItemMenu() : MenuItem(MENU_ITEM_MENU) {} void add_item(MenuItem *item) { @@ -97,7 +97,7 @@ class MenuItemEditable : public MenuItem { }; #ifdef USE_SELECT -class MenuItemSelect : public MenuItemEditable { +class MenuItemSelect final : public MenuItemEditable { public: explicit MenuItemSelect() : MenuItemEditable(MENU_ITEM_SELECT) {} void set_select_variable(select::Select *var) { this->select_var_ = var; } @@ -114,7 +114,7 @@ class MenuItemSelect : public MenuItemEditable { #endif #ifdef USE_NUMBER -class MenuItemNumber : public MenuItemEditable { +class MenuItemNumber final : public MenuItemEditable { public: explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {} void set_number_variable(number::Number *var) { this->number_var_ = var; } @@ -135,7 +135,7 @@ class MenuItemNumber : public MenuItemEditable { #endif #ifdef USE_SWITCH -class MenuItemSwitch : public MenuItemEditable { +class MenuItemSwitch final : public MenuItemEditable { public: explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {} void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; } @@ -158,7 +158,7 @@ class MenuItemSwitch : public MenuItemEditable { }; #endif -class MenuItemCommand : public MenuItem { +class MenuItemCommand final : public MenuItem { public: explicit MenuItemCommand() : MenuItem(MENU_ITEM_COMMAND) {} @@ -166,7 +166,7 @@ class MenuItemCommand : public MenuItem { bool select_prev() override; }; -class MenuItemCustom : public MenuItemEditable { +class MenuItemCustom final : public MenuItemEditable { public: explicit MenuItemCustom() : MenuItemEditable(MENU_ITEM_CUSTOM) {} template void add_on_next_callback(F &&cb) { this->on_next_callbacks_.add(std::forward(cb)); } diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index cdc53d56858..fc4721843fd 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -98,7 +98,7 @@ struct CustomPattern { std::optional> default_obis; }; -class DlmsMeterComponent : public Component, public uart::UARTDevice { +class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, std::optional> decryption_key, diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index 09143bf6b88..4dd23985d75 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -35,7 +35,7 @@ static const uint8_t DPS310_INIT_TIMEOUT = 20; // How long to wait for DPS static const uint8_t DPS310_NUM_COEF_REGS = 18; // Number of coefficients we need to read from the device static const int32_t DPS310_SCALE_FACTOR = 1572864; // Measurement compensation scale factor -class DPS310Component : public PollingComponent, public i2c::I2CDevice { +class DPS310Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 2004978cc66..238fc7b21af 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -6,7 +6,7 @@ namespace esphome::ds1307 { -class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { +class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -55,12 +55,12 @@ class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { } ds1307_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index 9e6bb088584..b3337539ce4 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -8,7 +8,7 @@ namespace esphome::ds2484 { -class DS2484OneWireBus : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { +class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 3642309c26a..321fbab824f 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -65,7 +65,7 @@ using MyData = dsmr_parser::ParsedData; #endif -class Dsmr : public Component, public uart::UARTDevice { +class Dsmr final : public Component, public uart::UARTDevice { public: Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval, uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key) diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.h b/esphome/components/duty_cycle/duty_cycle_sensor.h index 58beee946a2..564c47a2aa8 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.h +++ b/esphome/components/duty_cycle/duty_cycle_sensor.h @@ -16,7 +16,7 @@ struct DutyCycleSensorStore { static void gpio_intr(DutyCycleSensorStore *arg); }; -class DutyCycleSensor : public sensor::Sensor, public PollingComponent { +class DutyCycleSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index 9b1e10ea8c4..a9e91de0b19 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -12,7 +12,7 @@ namespace esphome::duty_time_sensor { -class DutyTimeSensor : public sensor::Sensor, public PollingComponent { +class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { public: void setup() override; void update() override; @@ -61,7 +61,7 @@ template class ResetAction : public BaseAction { void play(const Ts &...x) override { this->parent_->reset(); } }; -template class RunningCondition : public Condition, public Parented { +template class RunningCondition final : public Condition, public Parented { public: explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 6574037efb8..b0a8b4f83fd 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -30,7 +30,7 @@ struct UniverseConsumer { uint16_t consumers; }; -class E131Component : public esphome::Component { +class E131Component final : public esphome::Component { public: E131Component(); ~E131Component(); diff --git a/esphome/components/ee895/ee895.h b/esphome/components/ee895/ee895.h index ba8e594feac..1682e331469 100644 --- a/esphome/components/ee895/ee895.h +++ b/esphome/components/ee895/ee895.h @@ -7,7 +7,7 @@ namespace esphome::ee895 { /// This class implements support for the ee895 of temperature i2c sensors. -class EE895Component : public PollingComponent, public i2c::I2CDevice { +class EE895Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.h b/esphome/components/ektf2232/touchscreen/ektf2232.h index 45da74a2a5d..a4b9cbd5749 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.h +++ b/esphome/components/ektf2232/touchscreen/ektf2232.h @@ -10,7 +10,7 @@ namespace esphome::ektf2232 { using namespace touchscreen; -class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { +class EKTF2232Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/emc2101/emc2101.h b/esphome/components/emc2101/emc2101.h index 1fe03a26307..b3ec0c5dc12 100644 --- a/esphome/components/emc2101/emc2101.h +++ b/esphome/components/emc2101/emc2101.h @@ -25,7 +25,7 @@ enum Emc2101DACConversionRate { /// This class includes support for the EMC2101 i2c fan controller. /// The device has an output (PWM or DAC) and several sensors and this /// class is for the EMC2101 configuration. -class Emc2101Component : public Component, public i2c::I2CDevice { +class Emc2101Component final : public Component, public i2c::I2CDevice { public: /** Sets the mode of the output. * diff --git a/esphome/components/emc2101/output/emc2101_output.h b/esphome/components/emc2101/output/emc2101_output.h index 95077f55242..9a7ab0659a3 100644 --- a/esphome/components/emc2101/output/emc2101_output.h +++ b/esphome/components/emc2101/output/emc2101_output.h @@ -6,7 +6,7 @@ namespace esphome::emc2101 { /// This class allows to control the EMC2101 output. -class EMC2101Output : public output::FloatOutput { +class EMC2101Output final : public output::FloatOutput { public: EMC2101Output(Emc2101Component *parent) : parent_(parent) {} diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.h b/esphome/components/emc2101/sensor/emc2101_sensor.h index 2336ac2f15c..943e468e7dc 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.h +++ b/esphome/components/emc2101/sensor/emc2101_sensor.h @@ -7,7 +7,7 @@ namespace esphome::emc2101 { /// This class exposes the EMC2101 sensors. -class EMC2101Sensor : public PollingComponent { +class EMC2101Sensor final : public PollingComponent { public: EMC2101Sensor(Emc2101Component *parent) : parent_(parent) {} /** Used by ESPHome framework. */ diff --git a/esphome/components/emmeti/emmeti.h b/esphome/components/emmeti/emmeti.h index 9dc78ce07c2..2203bfdec7d 100644 --- a/esphome/components/emmeti/emmeti.h +++ b/esphome/components/emmeti/emmeti.h @@ -60,7 +60,7 @@ struct EmmetiState { uint8_t checksum = 0; }; -class EmmetiClimate : public climate_ir::ClimateIR { +class EmmetiClimate final : public climate_ir::ClimateIR { public: EmmetiClimate() : climate_ir::ClimateIR(EMMETI_TEMP_MIN, EMMETI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 67e7f5bffce..6db197a78c5 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -26,7 +26,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * The EmonTx processes incoming data frames via UART, * extracts tags and values, and publishes them to registered sensors. */ -class EmonTx : public Component, public uart::UARTDevice { +class EmonTx final : public Component, public uart::UARTDevice { public: EmonTx() = default; @@ -59,7 +59,7 @@ class EmonTx : public Component, public uart::UARTDevice { }; // Action to send command to emonTx -template class EmonTxSendCommandAction : public Action, public Parented { +template class EmonTxSendCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, command) diff --git a/esphome/components/emontx/sensor/emontx_sensor.h b/esphome/components/emontx/sensor/emontx_sensor.h index 9714acdf0dc..88b396bbc44 100644 --- a/esphome/components/emontx/sensor/emontx_sensor.h +++ b/esphome/components/emontx/sensor/emontx_sensor.h @@ -5,7 +5,7 @@ namespace esphome::emontx { -class EmonTxSensor : public sensor::Sensor, public Component { +class EmonTxSensor final : public sensor::Sensor, public Component { public: void dump_config() override; }; diff --git a/esphome/components/endstop/endstop_cover.h b/esphome/components/endstop/endstop_cover.h index b910139bcd6..5319c74d7bc 100644 --- a/esphome/components/endstop/endstop_cover.h +++ b/esphome/components/endstop/endstop_cover.h @@ -7,7 +7,7 @@ namespace esphome::endstop { -class EndstopCover : public cover::Cover, public Component { +class EndstopCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/ens160_i2c/ens160_i2c.h b/esphome/components/ens160_i2c/ens160_i2c.h index 98318a7eca7..d5a0d21c623 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.h +++ b/esphome/components/ens160_i2c/ens160_i2c.h @@ -5,7 +5,7 @@ namespace esphome::ens160_i2c { -class ENS160I2CComponent : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { +class ENS160I2CComponent final : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { void dump_config() override; bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/ens160_spi/ens160_spi.h b/esphome/components/ens160_spi/ens160_spi.h index d4d3cf3ae99..821e89515f3 100644 --- a/esphome/components/ens160_spi/ens160_spi.h +++ b/esphome/components/ens160_spi/ens160_spi.h @@ -5,9 +5,9 @@ namespace esphome::ens160_spi { -class ENS160SPIComponent : public esphome::ens160_base::ENS160Component, - public spi::SPIDevice { +class ENS160SPIComponent final : public esphome::ens160_base::ENS160Component, + public spi::SPIDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/ens210/ens210.h b/esphome/components/ens210/ens210.h index f1520fc4834..fca20133b82 100644 --- a/esphome/components/ens210/ens210.h +++ b/esphome/components/ens210/ens210.h @@ -7,7 +7,7 @@ namespace esphome::ens210 { /// This class implements support for the ENS210 relative humidity and temperature i2c sensor. -class ENS210Component : public PollingComponent, public i2c::I2CDevice { +class ENS210Component final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void setup() override; diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 914fbd633b8..42c667b6585 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -16,7 +16,7 @@ enum ES7210BitsPerSample : uint8_t { ES7210_BITS_PER_SAMPLE_32 = 32, }; -class ES7210 : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7210 ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-bsp/ (accessed 20241219) diff --git a/esphome/components/es7243e/es7243e.h b/esphome/components/es7243e/es7243e.h index 6386ea529a7..47dc6122c9d 100644 --- a/esphome/components/es7243e/es7243e.h +++ b/esphome/components/es7243e/es7243e.h @@ -6,7 +6,7 @@ namespace esphome::es7243e { -class ES7243E : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7243E final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7243E ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-adf/ (accessed 20250116) diff --git a/esphome/components/es8156/es8156.h b/esphome/components/es8156/es8156.h index c3cec3dc14d..d29e8d16853 100644 --- a/esphome/components/es8156/es8156.h +++ b/esphome/components/es8156/es8156.h @@ -6,7 +6,7 @@ namespace esphome::es8156 { -class ES8156 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8156 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8311/es8311.h b/esphome/components/es8311/es8311.h index 1190bcb0aa0..ecc4b1014d7 100644 --- a/esphome/components/es8311/es8311.h +++ b/esphome/components/es8311/es8311.h @@ -42,7 +42,7 @@ struct ES8311Coefficient { uint8_t dac_osr; // dac osr }; -class ES8311 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8311 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index 1f744e25b37..b01acb69c1e 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -25,7 +25,7 @@ enum AdcInputMicLine : uint8_t { ADC_INPUT_MIC_DIFFERENCE, }; -class ES8388 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { #ifdef USE_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) diff --git a/esphome/components/es8388/select/adc_input_mic_select.h b/esphome/components/es8388/select/adc_input_mic_select.h index 29978f1623b..2d4e8d72db1 100644 --- a/esphome/components/es8388/select/adc_input_mic_select.h +++ b/esphome/components/es8388/select/adc_input_mic_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class ADCInputMicSelect : public select::Select, public Parented { +class ADCInputMicSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/es8388/select/dac_output_select.h b/esphome/components/es8388/select/dac_output_select.h index 030f12406e1..f63ee8d1ba4 100644 --- a/esphome/components/es8388/select/dac_output_select.h +++ b/esphome/components/es8388/select/dac_output_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class DacOutputSelect : public select::Select, public Parented { +class DacOutputSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index a140eeef775..aeff5af51c6 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -10,7 +10,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public InternalGPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index de8c8c23432..c85ddfc983f 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -87,7 +87,7 @@ enum BLEComponentState : uint8_t { BLE_COMPONENT_STATE_ACTIVE, }; -class ESP32BLE : public Component { +class ESP32BLE final : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } @@ -236,12 +236,12 @@ class ESP32BLE : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition : public Condition { +template class BLEEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; -template class BLEEnableAction : public Action { +template class BLEEnableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) @@ -249,7 +249,7 @@ template class BLEEnableAction : public Action { } }; -template class BLEDisableAction : public Action { +template class BLEDisableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 8b3899a681d..986778de579 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -34,7 +34,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component { +class ESP32BLEBeacon final : public Component { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} From e88f69b5f81550ecd50036e922014526a10d5629 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:05:05 +1200 Subject: [PATCH 110/343] Mark configurable classes as final (7/21: gcja5-hlw8032) (#16958) --- esphome/components/gcja5/gcja5.h | 2 +- esphome/components/gdk101/gdk101.h | 2 +- esphome/components/gl_r01_i2c/gl_r01_i2c.h | 2 +- .../components/globals/globals_component.h | 4 +-- .../components/gp2y1010au0f/gp2y1010au0f.h | 2 +- esphome/components/gp8403/gp8403.h | 2 +- .../components/gp8403/output/gp8403_output.h | 2 +- .../components/gpio/one_wire/gpio_one_wire.h | 2 +- .../gpio/output/gpio_binary_output.h | 2 +- esphome/components/gps/gps.h | 2 +- esphome/components/gps/time/gps_time.h | 2 +- esphome/components/graph/graph.h | 6 ++--- esphome/components/gree/gree.h | 2 +- esphome/components/gree/switch/gree_switch.h | 2 +- .../grove_gas_mc_v2/grove_gas_mc_v2.h | 2 +- .../grove_tb6612fng/grove_tb6612fng.h | 14 +++++----- .../components/growatt_solar/growatt_solar.h | 2 +- .../gt911/binary_sensor/gt911_button.h | 8 +++--- .../gt911/touchscreen/gt911_touchscreen.h | 2 +- esphome/components/haier/automation.h | 26 +++++++++---------- .../components/haier/button/self_cleaning.h | 2 +- .../components/haier/button/steri_cleaning.h | 2 +- esphome/components/haier/hon_climate.h | 2 +- esphome/components/haier/smartair2_climate.h | 2 +- esphome/components/haier/switch/beeper.h | 2 +- esphome/components/haier/switch/display.h | 2 +- esphome/components/haier/switch/health_mode.h | 2 +- esphome/components/haier/switch/quiet_mode.h | 2 +- .../components/havells_solar/havells_solar.h | 2 +- esphome/components/hbridge/fan/hbridge_fan.h | 4 +-- .../hbridge/light/hbridge_light_output.h | 2 +- .../hbridge/switch/hbridge_switch.h | 2 +- esphome/components/hc8/hc8.h | 4 +-- esphome/components/hdc1080/hdc1080.h | 2 +- esphome/components/hdc2010/hdc2010.h | 2 +- esphome/components/hdc2080/hdc2080.h | 2 +- esphome/components/hdc302x/hdc302x.h | 6 ++--- esphome/components/he60r/he60r.h | 2 +- esphome/components/heatpumpir/heatpumpir.h | 2 +- .../components/hitachi_ac344/hitachi_ac344.h | 2 +- .../components/hitachi_ac424/hitachi_ac424.h | 2 +- esphome/components/hlk_fm22x/hlk_fm22x.h | 12 ++++----- esphome/components/hlw8012/hlw8012.h | 2 +- esphome/components/hlw8032/hlw8032.h | 2 +- 44 files changed, 77 insertions(+), 77 deletions(-) diff --git a/esphome/components/gcja5/gcja5.h b/esphome/components/gcja5/gcja5.h index 30c9464b4a2..f25d864f1ab 100644 --- a/esphome/components/gcja5/gcja5.h +++ b/esphome/components/gcja5/gcja5.h @@ -7,7 +7,7 @@ namespace esphome::gcja5 { -class GCJA5Component : public Component, public uart::UARTDevice { +class GCJA5Component final : public Component, public uart::UARTDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index 2ef75262947..5a915940815 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -22,7 +22,7 @@ static const uint8_t GDK101_REG_READ_MEASURING_TIME = 0xB1; // Mesuring time static const uint8_t GDK101_REG_READ_10MIN_AVG = 0xB2; // Average radiation dose per 10 min static const uint8_t GDK101_REG_READ_1MIN_AVG = 0xB3; // Average radiation dose per 1 min -class GDK101Component : public PollingComponent, public i2c::I2CDevice { +class GDK101Component final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(rad_1m) SUB_SENSOR(rad_10m) diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.h b/esphome/components/gl_r01_i2c/gl_r01_i2c.h index 1d023c245ae..23a1dec3366 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.h +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.h @@ -6,7 +6,7 @@ namespace esphome::gl_r01_i2c { -class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { +class GLR01I2CComponent final : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 520c068e6f4..78d2bc5910b 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -7,7 +7,7 @@ namespace esphome::globals { -template class GlobalsComponent : public Component { +template class GlobalsComponent final : public Component { public: using value_type = T; explicit GlobalsComponent() = default; @@ -127,7 +127,7 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction : public Action { +template class GlobalVarSetAction final : public Action { public: explicit GlobalVarSetAction(C *parent) : parent_(parent) {} diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.h b/esphome/components/gp2y1010au0f/gp2y1010au0f.h index f3398ac4a3a..648e66d2ffe 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.h +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.h @@ -7,7 +7,7 @@ namespace esphome::gp2y1010au0f { -class GP2Y1010AU0FSensor : public sensor::Sensor, public PollingComponent { +class GP2Y1010AU0FSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/gp8403/gp8403.h b/esphome/components/gp8403/gp8403.h index d30d9674795..5d969c20d22 100644 --- a/esphome/components/gp8403/gp8403.h +++ b/esphome/components/gp8403/gp8403.h @@ -15,7 +15,7 @@ enum GP8403Model : uint8_t { GP8413, }; -class GP8403Component : public Component, public i2c::I2CDevice { +class GP8403Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gp8403/output/gp8403_output.h b/esphome/components/gp8403/output/gp8403_output.h index 8b1f920680d..ea3b7cd6f6e 100644 --- a/esphome/components/gp8403/output/gp8403_output.h +++ b/esphome/components/gp8403/output/gp8403_output.h @@ -7,7 +7,7 @@ namespace esphome::gp8403 { -class GP8403Output : public Component, public output::FloatOutput, public Parented { +class GP8403Output final : public Component, public output::FloatOutput, public Parented { public: void dump_config() override; float get_setup_priority() const override { return setup_priority::DATA - 1; } diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.h b/esphome/components/gpio/one_wire/gpio_one_wire.h index 02797b57371..e457b599e51 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.h +++ b/esphome/components/gpio/one_wire/gpio_one_wire.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOOneWireBus : public one_wire::OneWireBus, public Component { +class GPIOOneWireBus final : public one_wire::OneWireBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 4100cb94c2b..496afd131b2 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOBinaryOutput : public output::BinaryOutput, public Component { +class GPIOBinaryOutput final : public output::BinaryOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/gps/gps.h b/esphome/components/gps/gps.h index 9cd79e25b42..7771286edfc 100644 --- a/esphome/components/gps/gps.h +++ b/esphome/components/gps/gps.h @@ -22,7 +22,7 @@ class GPSListener { GPS *parent_; }; -class GPS : public PollingComponent, public uart::UARTDevice { +class GPS final : public PollingComponent, public uart::UARTDevice { public: void set_latitude_sensor(sensor::Sensor *latitude_sensor) { this->latitude_sensor_ = latitude_sensor; } void set_longitude_sensor(sensor::Sensor *longitude_sensor) { this->longitude_sensor_ = longitude_sensor; } diff --git a/esphome/components/gps/time/gps_time.h b/esphome/components/gps/time/gps_time.h index 3d6d870efc2..bd2049c46c0 100644 --- a/esphome/components/gps/time/gps_time.h +++ b/esphome/components/gps/time/gps_time.h @@ -6,7 +6,7 @@ namespace esphome::gps { -class GPSTime : public time::RealTimeClock, public GPSListener { +class GPSTime final : public time::RealTimeClock, public GPSListener { public: void update() override { this->from_tiny_gps_(this->get_tiny_gps()); }; void on_update(TinyGPSPlus &tiny_gps) override { diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h index a601e9eeb1b..dbedab6085d 100644 --- a/esphome/components/graph/graph.h +++ b/esphome/components/graph/graph.h @@ -42,7 +42,7 @@ enum ValuePositionType { VALUE_POSITION_TYPE_BELOW }; -class GraphLegend { +class GraphLegend final { public: void init(Graph *g); void set_name_font(display::BaseFont *font) { this->font_label_ = font; } @@ -105,7 +105,7 @@ class HistoryData { std::vector samples_; }; -class GraphTrace { +class GraphTrace final { public: void init(Graph *g); void set_name(std::string name) { name_ = std::move(name); } @@ -134,7 +134,7 @@ class GraphTrace { friend GraphLegend; }; -class Graph : public Component { +class Graph final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); void draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 1eb812ae467..2f10be3e6b0 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -79,7 +79,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; -class GreeClimate : public climate_ir::ClimateIR { +class GreeClimate final : public climate_ir::ClimateIR { public: GreeClimate() : climate_ir::ClimateIR(GREE_TEMP_MIN, GREE_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h index 9d9f187f9d5..1e82c83ae66 100644 --- a/esphome/components/gree/switch/gree_switch.h +++ b/esphome/components/gree/switch/gree_switch.h @@ -6,7 +6,7 @@ namespace esphome::gree { -class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { +class GreeModeBitSwitch final : public switch_::Switch, public Component, public Parented { public: GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {} diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index 38165ab68cd..545b6df97a9 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -7,7 +7,7 @@ namespace esphome::grove_gas_mc_v2 { -class GroveGasMultichannelV2Component : public PollingComponent, public i2c::I2CDevice { +class GroveGasMultichannelV2Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(tvoc) SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index c0216805198..a8648025b98 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -47,7 +47,7 @@ enum StepperModeTypeT { MICRO_STEPPING = 3, }; -class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { +class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -162,7 +162,7 @@ class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { }; template -class GROVETB6612FNGMotorRunAction : public Action, public Parented { +class GROVETB6612FNGMotorRunAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) TEMPLATABLE_VALUE(uint16_t, speed) @@ -183,7 +183,7 @@ class GROVETB6612FNGMotorRunAction : public Action, public Parented -class GROVETB6612FNGMotorBrakeAction : public Action, public Parented { +class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -191,7 +191,7 @@ class GROVETB6612FNGMotorBrakeAction : public Action, public Parented -class GROVETB6612FNGMotorStopAction : public Action, public Parented { +class GROVETB6612FNGMotorStopAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -199,19 +199,19 @@ class GROVETB6612FNGMotorStopAction : public Action, public Parented -class GROVETB6612FNGMotorStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->standby(); } }; template -class GROVETB6612FNGMotorNoStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->not_standby(); } }; template -class GROVETB6612FNGMotorChangeAddressAction : public Action, public Parented { +class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, address) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 27ae32cc46d..76d430737ad 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice { public: void loop() override; void update() override; diff --git a/esphome/components/gt911/binary_sensor/gt911_button.h b/esphome/components/gt911/binary_sensor/gt911_button.h index 5aab4570950..ccb725b50f4 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.h +++ b/esphome/components/gt911/binary_sensor/gt911_button.h @@ -7,10 +7,10 @@ namespace esphome::gt911 { -class GT911Button : public binary_sensor::BinarySensor, - public Component, - public GT911ButtonListener, - public Parented { +class GT911Button final : public binary_sensor::BinarySensor, + public Component, + public GT911ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 0f1eeae7207..465df528e5f 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -12,7 +12,7 @@ class GT911ButtonListener { virtual void update_button(uint8_t index, bool state) = 0; }; -class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: /// @brief Initialize the GT911 touchscreen. /// diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h index e345867d6f8..a81fd4bdb7d 100644 --- a/esphome/components/haier/automation.h +++ b/esphome/components/haier/automation.h @@ -6,7 +6,7 @@ namespace esphome::haier { -template class DisplayOnAction : public Action { +template class DisplayOnAction final : public Action { public: DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(true); } @@ -15,7 +15,7 @@ template class DisplayOnAction : public Action { HaierClimateBase *parent_; }; -template class DisplayOffAction : public Action { +template class DisplayOffAction final : public Action { public: DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(false); } @@ -24,7 +24,7 @@ template class DisplayOffAction : public Action { HaierClimateBase *parent_; }; -template class BeeperOnAction : public Action { +template class BeeperOnAction final : public Action { public: BeeperOnAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(true); } @@ -33,7 +33,7 @@ template class BeeperOnAction : public Action { HonClimate *parent_; }; -template class BeeperOffAction : public Action { +template class BeeperOffAction final : public Action { public: BeeperOffAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(false); } @@ -42,7 +42,7 @@ template class BeeperOffAction : public Action { HonClimate *parent_; }; -template class VerticalAirflowAction : public Action { +template class VerticalAirflowAction final : public Action { public: VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) @@ -52,7 +52,7 @@ template class VerticalAirflowAction : public Action { HonClimate *parent_; }; -template class HorizontalAirflowAction : public Action { +template class HorizontalAirflowAction final : public Action { public: HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) @@ -62,7 +62,7 @@ template class HorizontalAirflowAction : public Action { HonClimate *parent_; }; -template class HealthOnAction : public Action { +template class HealthOnAction final : public Action { public: HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(true); } @@ -71,7 +71,7 @@ template class HealthOnAction : public Action { HaierClimateBase *parent_; }; -template class HealthOffAction : public Action { +template class HealthOffAction final : public Action { public: HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(false); } @@ -80,7 +80,7 @@ template class HealthOffAction : public Action { HaierClimateBase *parent_; }; -template class StartSelfCleaningAction : public Action { +template class StartSelfCleaningAction final : public Action { public: StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_self_cleaning(); } @@ -89,7 +89,7 @@ template class StartSelfCleaningAction : public Action { HonClimate *parent_; }; -template class StartSteriCleaningAction : public Action { +template class StartSteriCleaningAction final : public Action { public: StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } @@ -98,7 +98,7 @@ template class StartSteriCleaningAction : public Action { HonClimate *parent_; }; -template class PowerOnAction : public Action { +template class PowerOnAction final : public Action { public: PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_on_command(); } @@ -107,7 +107,7 @@ template class PowerOnAction : public Action { HaierClimateBase *parent_; }; -template class PowerOffAction : public Action { +template class PowerOffAction final : public Action { public: PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_off_command(); } @@ -116,7 +116,7 @@ template class PowerOffAction : public Action { HaierClimateBase *parent_; }; -template class PowerToggleAction : public Action { +template class PowerToggleAction final : public Action { public: PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->toggle_power(); } diff --git a/esphome/components/haier/button/self_cleaning.h b/esphome/components/haier/button/self_cleaning.h index 9d330e4dfe4..fc5a73b1e88 100644 --- a/esphome/components/haier/button/self_cleaning.h +++ b/esphome/components/haier/button/self_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SelfCleaningButton : public button::Button, public Parented { +class SelfCleaningButton final : public button::Button, public Parented { public: SelfCleaningButton() = default; diff --git a/esphome/components/haier/button/steri_cleaning.h b/esphome/components/haier/button/steri_cleaning.h index cac02dd2678..4799c0e2aed 100644 --- a/esphome/components/haier/button/steri_cleaning.h +++ b/esphome/components/haier/button/steri_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SteriCleaningButton : public button::Button, public Parented { +class SteriCleaningButton final : public button::Button, public Parented { public: SteriCleaningButton() = default; diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 5b477a5cea1..ba36e6a8fbd 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -35,7 +35,7 @@ struct HonSettings { bool quiet_mode_state{false}; }; -class HonClimate : public HaierClimateBase { +class HonClimate final : public HaierClimateBase { #ifdef USE_SENSOR public: enum class SubSensorType { diff --git a/esphome/components/haier/smartair2_climate.h b/esphome/components/haier/smartair2_climate.h index 68b0e4a0db2..dc9a60f06f6 100644 --- a/esphome/components/haier/smartair2_climate.h +++ b/esphome/components/haier/smartair2_climate.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class Smartair2Climate : public HaierClimateBase { +class Smartair2Climate final : public HaierClimateBase { public: Smartair2Climate(); Smartair2Climate(const Smartair2Climate &) = delete; diff --git a/esphome/components/haier/switch/beeper.h b/esphome/components/haier/switch/beeper.h index 2d20f1cd835..f27b419a2ea 100644 --- a/esphome/components/haier/switch/beeper.h +++ b/esphome/components/haier/switch/beeper.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class BeeperSwitch : public switch_::Switch, public Parented { +class BeeperSwitch final : public switch_::Switch, public Parented { public: BeeperSwitch() = default; diff --git a/esphome/components/haier/switch/display.h b/esphome/components/haier/switch/display.h index 9baf3b9fb8c..bf60538e115 100644 --- a/esphome/components/haier/switch/display.h +++ b/esphome/components/haier/switch/display.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class DisplaySwitch : public switch_::Switch, public Parented { +class DisplaySwitch final : public switch_::Switch, public Parented { public: DisplaySwitch() = default; diff --git a/esphome/components/haier/switch/health_mode.h b/esphome/components/haier/switch/health_mode.h index ec77b1638aa..f5d3dad0f2f 100644 --- a/esphome/components/haier/switch/health_mode.h +++ b/esphome/components/haier/switch/health_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class HealthModeSwitch : public switch_::Switch, public Parented { +class HealthModeSwitch final : public switch_::Switch, public Parented { public: HealthModeSwitch() = default; diff --git a/esphome/components/haier/switch/quiet_mode.h b/esphome/components/haier/switch/quiet_mode.h index 8ef7b5bb894..f1ab85f4e31 100644 --- a/esphome/components/haier/switch/quiet_mode.h +++ b/esphome/components/haier/switch/quiet_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class QuietModeSwitch : public switch_::Switch, public Parented { +class QuietModeSwitch final : public switch_::Switch, public Parented { public: QuietModeSwitch() = default; diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index c54b0dcf148..ec6d5b56570 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 62149d99cd0..187b6d2a97f 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -12,7 +12,7 @@ enum DecayMode { DECAY_MODE_FAST = 1, }; -class HBridgeFan : public Component, public fan::Fan { +class HBridgeFan final : public Component, public fan::Fan { public: HBridgeFan(int speed_count, DecayMode decay_mode) : speed_count_(speed_count), decay_mode_(decay_mode) {} @@ -46,7 +46,7 @@ class HBridgeFan : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction : public Action { +template class BrakeAction final : public Action { public: explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 16408f24f1b..c0107fdc0d6 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -7,7 +7,7 @@ namespace esphome::hbridge { -class HBridgeLightOutput : public Component, public light::LightOutput { +class HBridgeLightOutput final : public Component, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } diff --git a/esphome/components/hbridge/switch/hbridge_switch.h b/esphome/components/hbridge/switch/hbridge_switch.h index de867271fe2..5c039589915 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.h +++ b/esphome/components/hbridge/switch/hbridge_switch.h @@ -16,7 +16,7 @@ enum RelayState : uint8_t { RELAY_STATE_UNKNOWN = 4, }; -class HBridgeSwitch : public switch_::Switch, public Component { +class HBridgeSwitch final : public switch_::Switch, public Component { public: void set_on_pin(GPIOPin *pin) { this->on_pin_ = pin; } void set_off_pin(GPIOPin *pin) { this->off_pin_ = pin; } diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index b060f38a806..681dffe4f6c 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -9,7 +9,7 @@ namespace esphome::hc8 { -class HC8Component : public PollingComponent, public uart::UARTDevice { +class HC8Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -26,7 +26,7 @@ class HC8Component : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction : public Action, public Parented { +template class HC8CalibrateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, baseline) diff --git a/esphome/components/hdc1080/hdc1080.h b/esphome/components/hdc1080/hdc1080.h index 1e3bf777888..21580ff9abb 100644 --- a/esphome/components/hdc1080/hdc1080.h +++ b/esphome/components/hdc1080/hdc1080.h @@ -6,7 +6,7 @@ namespace esphome::hdc1080 { -class HDC1080Component : public PollingComponent, public i2c::I2CDevice { +class HDC1080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/hdc2010/hdc2010.h b/esphome/components/hdc2010/hdc2010.h index ad6df3ff48d..95c8c24e609 100644 --- a/esphome/components/hdc2010/hdc2010.h +++ b/esphome/components/hdc2010/hdc2010.h @@ -6,7 +6,7 @@ namespace esphome::hdc2010 { -class HDC2010Component : public PollingComponent, public i2c::I2CDevice { +class HDC2010Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } diff --git a/esphome/components/hdc2080/hdc2080.h b/esphome/components/hdc2080/hdc2080.h index daa10d371d6..8d86a7d41cb 100644 --- a/esphome/components/hdc2080/hdc2080.h +++ b/esphome/components/hdc2080/hdc2080.h @@ -6,7 +6,7 @@ namespace esphome::hdc2080 { -class HDC2080Component : public PollingComponent, public i2c::I2CDevice { +class HDC2080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } void set_humidity(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index 6afea0a8c0f..cc5343ee89c 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -20,7 +20,7 @@ enum HDC302XPowerMode : uint8_t { Datasheet: https://www.ti.com/lit/ds/symlink/hdc3020.pdf */ -class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { +class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -48,7 +48,7 @@ class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction : public Action, public Parented { +template class HeaterOnAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, power) TEMPLATABLE_VALUE(uint32_t, duration) @@ -60,7 +60,7 @@ template class HeaterOnAction : public Action, public Par } }; -template class HeaterOffAction : public Action, public Parented { +template class HeaterOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_heater(); } }; diff --git a/esphome/components/he60r/he60r.h b/esphome/components/he60r/he60r.h index e7b5c97969d..ef8dde48041 100644 --- a/esphome/components/he60r/he60r.h +++ b/esphome/components/he60r/he60r.h @@ -7,7 +7,7 @@ namespace esphome::he60r { -class HE60rCover : public cover::Cover, public Component, public uart::UARTDevice { +class HE60rCover final : public cover::Cover, public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index a277424df6b..8e0668d59db 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -93,7 +93,7 @@ enum VerticalDirection { const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius -class HeatpumpIRClimate : public climate_ir::ClimateIR { +class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: HeatpumpIRClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index b9d776cc59d..c5773ac222b 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -75,7 +75,7 @@ const uint16_t HITACHI_AC344_BITS = HITACHI_AC344_STATE_LENGTH * 8; #define GETBIT8(a, b) ((a) & ((uint8_t) 1 << (b))) #define GETBITS8(data, offset, size) (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC344_TEMP_MIN, HITACHI_AC344_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.h b/esphome/components/hitachi_ac424/hitachi_ac424.h index ef7f128a5a2..31efd98c3dd 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.h +++ b/esphome/components/hitachi_ac424/hitachi_ac424.h @@ -77,7 +77,7 @@ const uint16_t HITACHI_AC424_BITS = HITACHI_AC424_STATE_LENGTH * 8; #define HITACHI_AC424_GETBITS8(data, offset, size) \ (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC424_TEMP_MIN, HITACHI_AC424_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index fd8257b435a..34246f52f0d 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -71,7 +71,7 @@ enum HlkFm22xFaceDirection { FACE_DIRECTION_UP = 0x10, }; -class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { +class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -141,7 +141,7 @@ class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, name) TEMPLATABLE_VALUE(uint8_t, direction) @@ -153,7 +153,7 @@ template class EnrollmentAction : public Action, public P } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int16_t, face_id) @@ -163,17 +163,17 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_faces(); } }; -template class ScanAction : public Action, public Parented { +template class ScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->scan_face(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; diff --git a/esphome/components/hlw8012/hlw8012.h b/esphome/components/hlw8012/hlw8012.h index d1d340bf45d..06911324986 100644 --- a/esphome/components/hlw8012/hlw8012.h +++ b/esphome/components/hlw8012/hlw8012.h @@ -23,7 +23,7 @@ enum HLW8012SensorModels { #define USE_PCNT false #endif -class HLW8012Component : public PollingComponent { +class HLW8012Component final : public PollingComponent { public: HLW8012Component() : cf_store_(*pulse_counter::get_storage(USE_PCNT)), cf1_store_(*pulse_counter::get_storage(USE_PCNT)) {} diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h index d4c7dbd26c4..56fd27a15ac 100644 --- a/esphome/components/hlw8032/hlw8032.h +++ b/esphome/components/hlw8032/hlw8032.h @@ -6,7 +6,7 @@ namespace esphome::hlw8032 { -class HLW8032Component : public Component, public uart::UARTDevice { +class HLW8032Component final : public Component, public uart::UARTDevice { public: void loop() override; void dump_config() override; From 2fe67a6eda5b7a69746e7dcedd363acadb03d18a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:17:47 +1200 Subject: [PATCH 111/343] [graphical_display_menu] Mark configurable classes as final (#17129) --- .../graphical_display_menu.cpp | 12 ++++++------ .../graphical_display_menu/graphical_display_menu.h | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index 81971e457cd..b3c3b27e066 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -118,7 +118,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const for (size_t i = 0; max_item_index >= 0 && i <= static_cast(max_item_index); i++) { const auto *item = this->displayed_item_->get_item(i); const bool selected = i == this->cursor_index_; - const display::Rect item_dimensions = this->measure_item(display, item, bounds, selected); + const display::Rect item_dimensions = this->measure_item_(display, item, bounds, selected); menu_dimensions.push_back(item_dimensions); total_height += item_dimensions.h + (i == 0 ? 0 : y_padding); @@ -181,7 +181,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const dimensions.y = y_offset; dimensions.x = bounds->x; - this->draw_item(display, item, &dimensions, selected); + this->draw_item_(display, item, &dimensions, selected); y_offset += dimensions.h + y_padding; } @@ -189,8 +189,8 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const display->end_clipping(); } -display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +display::Rect GraphicalDisplayMenu::measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { display::Rect dimensions(0, 0, 0, 0); if (selected) { @@ -218,8 +218,8 @@ display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, cons return dimensions; } -inline void GraphicalDisplayMenu::draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +inline void GraphicalDisplayMenu::draw_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { const auto background_color = selected ? this->foreground_color_ : this->background_color_; const auto foreground_color = selected ? this->background_color_ : this->foreground_color_; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ce1db185251..ccdf3d304c5 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -33,7 +33,7 @@ struct MenuItemValueArguments { bool is_menu_editing; }; -class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { +class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponent { public: void setup() override; void dump_config() override; @@ -53,10 +53,10 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { void draw_menu() override; void draw_menu_internal_(display::Display *display, const display::Rect *bounds); void draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) override; - virtual display::Rect measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); - virtual void draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); + display::Rect measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, bool selected); + void draw_item_(display::Display *display, const display_menu_base::MenuItem *item, const display::Rect *bounds, + bool selected); void update() override; void on_before_show() override; @@ -73,7 +73,7 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { CallbackManager on_redraw_callbacks_{}; }; -class GraphicalDisplayMenuOnRedrawTrigger : public Trigger { +class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { public: explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); From 614eae7a3b29915d24130e81bf4055b1268cf7f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 23:18:06 -0500 Subject: [PATCH 112/343] [dashboard_import] Store package_import_url in flash on ESP8266 (#17127) --- esphome/components/dashboard_import/__init__.py | 2 +- esphome/components/dashboard_import/dashboard_import.cpp | 7 ++++--- esphome/components/dashboard_import/dashboard_import.h | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 911fc387a0a..000db307b96 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -77,7 +77,7 @@ async def to_code(config): url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: url += "?full_config" - cg.add(dashboard_import_ns.set_package_import_url(url)) + cg.add(dashboard_import_ns.set_package_import_url(cg.FlashStringLiteral(url))) def import_config( diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index f553adf273a..adc01cc0a86 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -2,9 +2,10 @@ namespace esphome::dashboard_import { -static const char *g_package_import_url = ""; // NOLINT +static const char EMPTY_URL[] PROGMEM = ""; // NOLINT +static ProgmemStr g_package_import_url = reinterpret_cast(EMPTY_URL); // NOLINT -const char *get_package_import_url() { return g_package_import_url; } -void set_package_import_url(const char *url) { g_package_import_url = url; } +ProgmemStr get_package_import_url() { return g_package_import_url; } +void set_package_import_url(ProgmemStr url) { g_package_import_url = url; } } // namespace esphome::dashboard_import diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index 19f69b85461..166fd8b7bec 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,8 +1,10 @@ #pragma once +#include "esphome/core/progmem.h" + namespace esphome::dashboard_import { -const char *get_package_import_url(); -void set_package_import_url(const char *url); +ProgmemStr get_package_import_url(); +void set_package_import_url(ProgmemStr url); } // namespace esphome::dashboard_import From 0df1db62057c0d35c91cf07b2c834bc364fd1099 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:01:22 -0500 Subject: [PATCH 113/343] Bump bundled esphome-device-builder to 1.0.13 (#17132) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1d39644ab8c..214d6c78412 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.13 RUN \ platformio settings set enable_telemetry No \ From 24835769098daa93e16a72247d6c2b3a4efa2d46 Mon Sep 17 00:00:00 2001 From: "Joseph C. Lehner" Date: Mon, 22 Jun 2026 16:51:10 +0200 Subject: [PATCH 114/343] [sx126x] Add data whitening options (#17102) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/sx126x/__init__.py | 8 ++++++++ esphome/components/sx126x/sx126x.cpp | 16 +++++++++++++++- esphome/components/sx126x/sx126x.h | 4 ++++ esphome/components/sx126x/sx126x_reg.h | 1 + tests/components/sx126x/common.yaml | 2 ++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index a4ba5c34f31..29e3ad5359f 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -41,6 +41,8 @@ CONF_SPREADING_FACTOR = "spreading_factor" CONF_SYNC_VALUE = "sync_value" CONF_TCXO_VOLTAGE = "tcxo_voltage" CONF_TCXO_DELAY = "tcxo_delay" +CONF_WHITENING_ENABLE = "whitening_enable" +CONF_WHITENING_INITIAL = "whitening_initial" sx126x_ns = cg.esphome_ns.namespace("sx126x") SX126x = sx126x_ns.class_("SX126x", cg.Component, spi.SPIDevice) @@ -232,6 +234,10 @@ CONFIG_SCHEMA = ( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=262144000)), ), + cv.Optional(CONF_WHITENING_ENABLE, default=False): cv.boolean, + cv.Optional(CONF_WHITENING_INITIAL, default=0x0100): cv.All( + cv.hex_int, cv.Range(min=0, max=0x1FF) + ), }, ) .extend(cv.COMPONENT_SCHEMA) @@ -285,6 +291,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_rf_switch(config[CONF_RF_SWITCH])) cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE])) cg.add(var.set_tcxo_delay(config[CONF_TCXO_DELAY])) + cg.add(var.set_whitening_enable(config[CONF_WHITENING_ENABLE])) + cg.add(var.set_whitening_initial(config[CONF_WHITENING_INITIAL])) NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index aed0105e1fb..af42c63bf41 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -251,6 +251,16 @@ void SX126x::configure() { this->write_register_(REG_CRC_POLYNOMIAL, buf, 2); } + // set whitening params + if (this->whitening_enable_) { + // according to the datasheet, section 12 table 12-1 "The user should not + // change the value of the 7 MSB of this register" + this->read_register_(REG_WHITENING_INITIAL, buf, 1); + buf[0] = (buf[0] & 0xFE) | ((this->whitening_initial_ >> 8) & 0x01); + buf[1] = this->whitening_initial_ & 0xFF; + this->write_register_(REG_WHITENING_INITIAL, buf, 2); + } + // set packet params and sync word this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { @@ -297,7 +307,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } else { buf[7] = 0x01; } - buf[8] = 0x00; + buf[8] = (this->whitening_enable_) ? 0x01 : 0x00; this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9); } } @@ -541,6 +551,10 @@ void SX126x::dump_config() { ESP_LOGCONFIG(TAG, " Sync Value: 0x%s", format_hex_to(hex_buf, this->sync_value_.data(), this->sync_value_.size())); } + ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->whitening_enable_)); + if (this->whitening_enable_) { + ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); + } if (this->is_failed()) { ESP_LOGE(TAG, "Configuring SX126x failed"); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 8298beb36e0..6816084df05 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -71,6 +71,8 @@ class SX126x : public Component, void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; } void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; } void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; } + void set_whitening_enable(bool whitening_enable) { this->whitening_enable_ = whitening_enable; } + void set_whitening_initial(uint16_t whitening_initial) { this->whitening_initial_ = whitening_initial; } void set_deviation(uint32_t deviation) { this->deviation_ = deviation; } void set_dio1_pin(GPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } @@ -128,6 +130,8 @@ class SX126x : public Component, uint8_t crc_size_{0}; uint16_t crc_polynomial_{0}; uint16_t crc_initial_{0}; + bool whitening_enable_{false}; + uint16_t whitening_initial_{0}; uint32_t deviation_{0}; uint32_t frequency_{0}; uint32_t payload_length_{0}; diff --git a/esphome/components/sx126x/sx126x_reg.h b/esphome/components/sx126x/sx126x_reg.h index c70817364fc..197a2aaadb6 100644 --- a/esphome/components/sx126x/sx126x_reg.h +++ b/esphome/components/sx126x/sx126x_reg.h @@ -52,6 +52,7 @@ enum SX126xOpCode : uint8_t { enum SX126xRegister : uint16_t { REG_VERSION_STRING = 0x0320, + REG_WHITENING_INITIAL = 0x06B8, REG_CRC_INITIAL = 0x06BC, REG_CRC_POLYNOMIAL = 0x06BE, REG_GFSK_SYNCWORD = 0x06C0, diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da71..05794ad1a89 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -21,6 +21,8 @@ sx126x: coding_rate: CR_4_6 tcxo_voltage: 1_8V tcxo_delay: 5ms + whitening_enable: false + whitening_initial: 0x1FF on_packet: then: - lambda: |- From 6c1724874b9ad35e921e18980444b581704d3043 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:04:22 -0500 Subject: [PATCH 115/343] Bump zeroconf from 0.149.16 to 0.150.0 (#17137) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b01b2a4c6ba..462438016e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ platformio==6.1.19 esptool==5.3.0 click==8.3.3 aioesphomeapi==45.3.1 -zeroconf==0.149.16 +zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 3a4831bd7e4aa74d800c67274236c574b921ed05 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Tue, 23 Jun 2026 02:34:11 +0530 Subject: [PATCH 116/343] [ble_nus] Atomic log-line framing (no partial ring-buffer writes) (#17105) Co-authored-by: Claude Opus 4.8 Co-authored-by: tomaszduda23 --- esphome/components/ble_nus/ble_nus.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index 71d98332e09..b566122f8ad 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -25,11 +25,14 @@ void BLENUS::write_array(const uint8_t *data, size_t len) { if (atomic_get(&this->tx_status_) == TX_DISABLED) { return; } - auto sent = ring_buf_put(&global_ble_tx_ring_buf, data, len); - if (sent < len) { - ESP_LOGE(TAG, "TX dropping %u bytes", len - sent); + // ring_buf_put() performs a partial write when the buffer is nearly full, which would commit a + // truncated fragment and corrupt the stream. Only write when the whole payload fits, so the byte + // stream never contains a partial message. + if (ring_buf_space_get(&global_ble_tx_ring_buf) < len) { + ESP_LOGE(TAG, "TX dropping %u bytes", len); return; } + ring_buf_put(&global_ble_tx_ring_buf, data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); @@ -197,6 +200,10 @@ void BLENUS::setup() { void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; + // make sure there is space for '\n' or entire message is dropped + if (ring_buf_space_get(&global_ble_tx_ring_buf) < message_len + 1) { + return; + } this->write_array(reinterpret_cast(message), message_len); const char c = '\n'; this->write_array(reinterpret_cast(&c), 1); From 1ace836744572002a305cc14b439e9f783ca066f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:41:21 -0400 Subject: [PATCH 117/343] [espidf] Don't fail framework check on broken unrelated PATH tools (#17053) --- esphome/espidf/framework.py | 16 +++++++++------- tests/unit_tests/test_espidf_framework.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 6f4aeef9f07..4053898a8e5 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -609,14 +609,16 @@ def _check_esphome_idf_framework_install( install = True if _check_stamp(env_stamp_file, stamp_info): _LOGGER.info("Checking ESP-IDF %s framework installation ...", version) - cmd = [ - get_system_python_path(), - str(idf_tools_path), - "--non-interactive", - "check", - ] - if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env): + # Validate via the managed tool-path resolution, not ``idf_tools.py check``: + # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a + # broken Homebrew openocd), which forced a toolchain reinstall on every build. + try: + _get_idf_tool_paths(framework_path, env) install = False + except RuntimeError as err: + _LOGGER.debug( + "ESP-IDF %s tool resolution failed, reinstalling: %s", version, err + ) # 4. Install framework tools if not installed or needs update if install: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index d89b93f4787..525cd55146f 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -298,6 +298,9 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.archive_extract_all") as extract, patch("esphome.espidf.framework.create_venv") as venv, patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok, + patch( + "esphome.espidf.framework._get_idf_tool_paths", return_value=([], {}) + ) as tool_paths, patch("esphome.espidf.framework._clone_idf_with_submodules") as clone, patch("esphome.espidf.framework._write_idf_version_txt"), patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"), @@ -308,7 +311,12 @@ def espidf_mocks(setup_core: Path): patch("esphome.espidf.framework.get_system_python_path", return_value="python"), ): yield SimpleNamespace( - download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone + download=download, + extract=extract, + venv=venv, + run_ok=run_ok, + tool_paths=tool_paths, + clone=clone, ) @@ -403,10 +411,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls( def test_check_esp_idf_install_check_command_failure_reinstalls( espidf_mocks: SimpleNamespace, ) -> None: - """A failing idf_tools check reinstalls tools (marker present, no re-extract).""" + """A failing tool-path resolution reinstalls tools (marker present, no re-extract).""" _mark_installed() - # idf_tools check fails -> install stays True; the later installs succeed. - espidf_mocks.run_ok.side_effect = [False, True, True, True] + # Managed tool resolution fails -> install stays True; the later installs succeed. + espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool") check_esp_idf_install(_IDF_VERSION, features=["fb"]) espidf_mocks.extract.assert_not_called() From 5fcf656806e93667b9a268cf81513f1847d76c64 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:45:22 -0500 Subject: [PATCH 118/343] Bump bundled esphome-device-builder to 1.0.14 (#17139) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 214d6c78412..bf37d6d88bf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,7 +32,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.13 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 RUN \ platformio settings set enable_telemetry No \ From 69d700727d6a0456a03ea30a8a0728ebffc85b4c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:25:24 +1200 Subject: [PATCH 119/343] [docker] Remove dead HA addon env exports (streamer_mode, relative_url) (#17140) --- docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index d4628ffa832..dff61fd2f3c 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -19,14 +19,6 @@ if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi -if bashio::config.true 'streamer_mode'; then - export ESPHOME_STREAMER_MODE=true -fi - -if bashio::config.has_value 'relative_url'; then - export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url') -fi - if bashio::config.has_value 'default_compile_process_limit'; then export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit') else From c70d56807fd5fe7eb42fb551cf18ac173066f0e7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:44:58 +1200 Subject: [PATCH 120/343] [motion] Make motion test configs mergeable in CI (#17149) --- tests/components/bmi270/common.yaml | 34 ++++++++++++++++++--------- tests/components/lsm6ds/common.yaml | 36 +++++++++++++++++++---------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0ffb1c62813..0f9b70631ce 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -3,52 +3,64 @@ sensor: name: "BMI270 Temperature" - platform: motion + motion_id: bmi270_motion type: acceleration_x - name: "Accel X" + name: "BMI270 Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: bmi270_motion type: acceleration_y - name: "Accel Y" + name: "BMI270 Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: bmi270_motion type: acceleration_z - name: "Accel Z" + name: "BMI270 Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: bmi270_motion type: gyroscope_x - name: "Gyro X" + name: "BMI270 Gyro X" - platform: motion + motion_id: bmi270_motion type: gyroscope_y - name: "Gyro Y" + name: "BMI270 Gyro Y" - platform: motion + motion_id: bmi270_motion type: gyroscope_z - name: "Gyro Z" + name: "BMI270 Gyro Z" - platform: motion + motion_id: bmi270_motion type: angular_rate_x - name: "Angular Rate X" + name: "BMI270 Angular Rate X" - platform: motion + motion_id: bmi270_motion type: angular_rate_y - name: "Angular Rate Y" + name: "BMI270 Angular Rate Y" - platform: motion + motion_id: bmi270_motion type: angular_rate_z - name: "Angular Rate Z" + name: "BMI270 Angular Rate Z" - platform: motion + motion_id: bmi270_motion type: pitch - name: "Pitch" + name: "BMI270 Pitch" - platform: motion + motion_id: bmi270_motion type: roll - name: "Roll" + name: "BMI270 Roll" motion: - platform: bmi270 + id: bmi270_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index 832254781f0..aeacd314481 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -1,54 +1,66 @@ sensor: - platform: lsm6ds - name: "lsm6ds Temperature" + name: "LSM6DS Temperature" - platform: motion + motion_id: lsm6ds_motion type: acceleration_x - name: "Accel X" + name: "LSM6DS Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: lsm6ds_motion type: acceleration_y - name: "Accel Y" + name: "LSM6DS Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: lsm6ds_motion type: acceleration_z - name: "Accel Z" + name: "LSM6DS Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: lsm6ds_motion type: gyroscope_x - name: "Gyro X" + name: "LSM6DS Gyro X" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_y - name: "Gyro Y" + name: "LSM6DS Gyro Y" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_z - name: "Gyro Z" + name: "LSM6DS Gyro Z" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_x - name: "Angular Rate X" + name: "LSM6DS Angular Rate X" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_y - name: "Angular Rate Y" + name: "LSM6DS Angular Rate Y" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_z - name: "Angular Rate Z" + name: "LSM6DS Angular Rate Z" - platform: motion + motion_id: lsm6ds_motion type: pitch - name: "Pitch" + name: "LSM6DS Pitch" - platform: motion + motion_id: lsm6ds_motion type: roll - name: "Roll" + name: "LSM6DS Roll" motion: - platform: lsm6ds + id: lsm6ds_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 41747c2de736f8f84d8970c69e7e788a3b5f5f82 Mon Sep 17 00:00:00 2001 From: arunderwood Date: Mon, 22 Jun 2026 23:50:02 -0700 Subject: [PATCH 121/343] [epaper_spi] Add support for the Inkplate 2 (#16856) --- esphome/components/epaper_spi/colorconv.h | 17 ++ .../epaper_spi/epaper_spi_inkplate2.cpp | 148 ++++++++++++++++++ .../epaper_spi/epaper_spi_inkplate2.h | 33 ++++ .../components/epaper_spi/models/inkplate2.py | 52 ++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++ 5 files changed, 271 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate2.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_inkplate2.h create mode 100644 esphome/components/epaper_spi/models/inkplate2.py diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index a2ea28f4b68..d4ffd034a10 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -64,4 +64,21 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ } } +/** Map RGB color to discrete BWR (black/white/red) 3 color key + * + * Convenience wrapper over color_to_bwyr for panels without a yellow ink; the yellow corner is + * folded into white. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_red Native value for red + * @return Converted native hardware color value + */ +template +constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_red) { + return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp new file mode 100644 index 00000000000..fc0d674246f --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -0,0 +1,148 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate2) + +#include "epaper_spi_inkplate2.h" +#include "colorconv.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate2"; + +// Map RGB to the panel's black/white/red via the shared converter. +enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; + +static Inkplate2Color to_inkplate2_color(Color color) { + return color_to_bwr(color, Inkplate2Color::BLACK, Inkplate2Color::WHITE, Inkplate2Color::RED); +} + +void EPaperInkplate2::power_on() { + // Power-on (0x04) leads the init sequence, so there is nothing to do here. + ESP_LOGV(TAG, "Power on"); +} + +void EPaperInkplate2::power_off() { + ESP_LOGV(TAG, "Power off"); + this->cmd_data(0x50, {0xF7}); // VCOM and data interval + this->command(0x02); // power off +} + +void EPaperInkplate2::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); // full refresh only; partial is unused + // Send 0x11 then 0x12 back-to-back: 0x11 raises busy until the refresh finishes, so waiting for idle + // between them (as the state machine does between states) would add a ~16s stall. + this->cmd_data(0x11, {0x00}); // stop data transfer + this->command(0x12); // display refresh +} + +void EPaperInkplate2::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +void EPaperInkplate2::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); // clipping active: defer to the base per-pixel path + return; + } + + const size_t half_buffer = this->buffer_length_ / 2; + + // Plane encoding: B/W plane 1=white, 0=black; red plane 0=red, 1=no-red. + uint8_t bw_byte; + uint8_t red_byte; + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + bw_byte = 0x00; + red_byte = 0xFF; + break; + case Inkplate2Color::RED: + bw_byte = 0xFF; + red_byte = 0x00; + break; + case Inkplate2Color::WHITE: + default: + bw_byte = 0xFF; + red_byte = 0xFF; + break; + } + + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = bw_byte; + for (size_t i = half_buffer; i < this->buffer_length_; i++) + this->buffer_[i] = red_byte; + + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaperInkplate2::clear() { this->fill(COLOR_ON); } + +void HOT EPaperInkplate2::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const size_t half_buffer = this->buffer_length_ / 2; + const size_t pos = y * this->row_width_ + x / 8; + const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill() for plane encoding + + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + this->buffer_[pos] &= ~mask; + this->buffer_[pos + half_buffer] |= mask; + break; + case Inkplate2Color::RED: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] &= ~mask; + break; + case Inkplate2Color::WHITE: + default: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] |= mask; + break; + } +} + +bool HOT EPaperInkplate2::send_buffer_range_(size_t end, uint32_t start_time) { + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + size_t buf_idx = 0; + while (this->current_data_index_ < end) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + buf_idx = 0; + if (millis() - start_time > MAX_TRANSFER_TIME) + return false; // yield; resume next loop + } + } + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + return true; +} + +bool HOT EPaperInkplate2::transfer_data() { + const uint32_t start_time = millis(); + const size_t half_buffer = this->buffer_length_ / 2; + + // Black/white plane (first half) then red plane (second half). + if (this->current_data_index_ == 0) + this->command(0x10); + if (this->current_data_index_ < half_buffer && !this->send_buffer_range_(half_buffer, start_time)) + return false; + + if (this->current_data_index_ == half_buffer) + this->command(0x13); + if (!this->send_buffer_range_(this->buffer_length_, start_time)) + return false; + + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h new file mode 100644 index 00000000000..657eca47590 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -0,0 +1,33 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 2: 104x212 black/white/red (BWR) e-paper, UC8xxx-family controller. +class EPaperInkplate2 final : public EPaperBase { + public: + EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. + this->buffer_length_ = this->row_width_ * this->height_ * 2; + } + + void fill(Color color) override; + void clear() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + // Streams buffer_[current_data_index_ .. end) in chunks; returns false if it yields on MAX_TRANSFER_TIME. + bool send_buffer_range_(size_t end, uint32_t start_time); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/inkplate2.py b/esphome/components/epaper_spi/models/inkplate2.py new file mode 100644 index 00000000000..f1a952ce105 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate2.py @@ -0,0 +1,52 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library + +from . import EpaperModel + + +class Inkplate2Model(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate2", **kwargs): + super().__init__(name, class_name, **kwargs) + + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x04,), # power on + ( + 0x00, # panel setting + 0x0F, # LUT from OTP + 0x89, # temperature/boost/timing + ), + ( + 0x61, # resolution + width, # width: 1 byte + height >> 8, # height: 2 bytes, high byte first ... + height & 0xFF, # ... then low byte + ), + ( + 0x50, # VCOM and data interval + 0x77, + ), + ) + + +# Native orientation is portrait (104x212); use `rotation: 90` for the board's landscape orientation. +inkplate2 = Inkplate2Model( + "inkplate2", + width=104, + height=212, + data_rate="10MHz", + # A full 3-color refresh takes ~20s, so don't allow updates faster than that. + minimum_update_interval="30s", + # Default GPIO pins for the on-board Inkplate 2 wiring. + reset_pin=19, + dc_pin=33, + cs_pin=15, + busy_pin={ + "number": 32, + "inverted": True, # hardware: LOW=busy, HIGH=idle + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8a420f299a9..6d5e276582d 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -161,3 +161,24 @@ display: busy_pin: allow_other_uses: true number: GPIO4 + + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) + - platform: epaper_spi + spi_id: spi_bus + model: inkplate2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); From 9614bc20a0c4b08216d33223b9c50f9f3b457966 Mon Sep 17 00:00:00 2001 From: Zach Isbach Date: Tue, 23 Jun 2026 04:00:19 -0700 Subject: [PATCH 122/343] [epaper_spi] Add support for Waveshare 2.13" V4 series B (R/B/W) (#16828) --- .../epaper_spi/epaper_waveshare_b.cpp | 13 ++++++++++ .../epaper_spi/epaper_waveshare_b.h | 19 ++++++++++++++ .../components/epaper_spi/epaper_weact_3c.cpp | 2 +- .../components/epaper_spi/epaper_weact_3c.h | 3 +++ .../epaper_spi/models/waveshare_b.py | 26 +++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++++++++++++++ 6 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 esphome/components/epaper_spi/epaper_waveshare_b.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare_b.h create mode 100644 esphome/components/epaper_spi/models/waveshare_b.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.cpp b/esphome/components/epaper_spi/epaper_waveshare_b.cpp new file mode 100644 index 00000000000..6875811b9b3 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.cpp @@ -0,0 +1,13 @@ +#include "epaper_waveshare_b.h" + +namespace esphome::epaper_spi { + +bool EpaperWaveshareB::reset() { + if (EPaperBase::reset()) { + this->command(0x12); + return true; + } + return false; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.h b/esphome/components/epaper_spi/epaper_waveshare_b.h new file mode 100644 index 00000000000..3a391731d8a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.h @@ -0,0 +1,19 @@ +#pragma once +#include "epaper_weact_3c.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare (B) series BWR e-paper displays using SSD1680-compatible controllers. + * Waveshare uses 0=red, 1=no-red, the inverse of EPaperWeAct3C + */ +class EpaperWaveshareB : public EPaperWeAct3C { + public: + using EPaperWeAct3C::EPaperWeAct3C; + + protected: + bool reset() override; + uint8_t transform_red_byte(uint8_t byte) const override { return static_cast(~byte); } +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index d4dac7076c4..ad2021ed64f 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -144,7 +144,7 @@ bool HOT EPaperWeAct3C::transfer_data() { size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->buffer_[red_offset + this->current_data_index_ + i]; + bytes_to_send[i] = this->transform_red_byte(this->buffer_[red_offset + this->current_data_index_ + i]); } this->write_array(bytes_to_send, bytes_to_copy); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index 2df6f1ba097..a31c2be8176 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -34,6 +34,9 @@ class EPaperWeAct3C : public EPaperBase { void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; + + // Hook for subclasses to transform red plane bytes before they go on the wire. + virtual uint8_t transform_red_byte(uint8_t byte) const { return byte; } }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py new file mode 100644 index 00000000000..688e7164566 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -0,0 +1,26 @@ +from . import EpaperModel + + +class WaveshareB(EpaperModel): + def __init__(self, name, **defaults): + super().__init__(name, "EpaperWaveshareB", **defaults) + + def get_init_sequence(self, config): + _, height = self.get_dimensions(config) + h = height - 1 + return ( + (0x01, h & 0xFF, h >> 8, 0x00), # Driver output control + (0x11, 0x03), # Data entry mode + (0x3C, 0x05), # Border waveform + (0x18, 0x80), # Internal temperature sensor + (0x21, 0x80, 0x80), # Display update control + ) + + +WaveshareB( + "waveshare-2.13in-bv4", + width=122, + height=250, + data_rate="10MHz", + minimum_update_interval="1s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6d5e276582d..60e4008f4f1 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -162,6 +162,27 @@ display: allow_other_uses: true number: GPIO4 + # Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv4 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) - platform: epaper_spi spi_id: spi_bus From 225d426d95426bd756b8c0c9c69251c298bcc50e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:40:16 +1200 Subject: [PATCH 123/343] [core] Use CORE.is_* platform helpers in __main__ (#17144) --- esphome/__main__.py | 20 +++------- tests/unit_tests/test_main.py | 70 +++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 35ab767cf74..48fee1e97e9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -52,11 +52,6 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_RP2040, SECRETS_FILES, Toolchain, ) @@ -359,7 +354,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2040 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -406,7 +401,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2040 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -984,7 +979,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: + if CORE.is_rp2040: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1169,10 +1164,10 @@ def upload_program( check_permissions(host) exit_code = 1 - if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): + if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: + elif CORE.is_rp2040 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1629,10 +1624,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if ( - successful_device is None - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 - ): + if successful_device is None and CORE.is_rp2040: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 33888956b39..b2011259c19 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -159,9 +159,12 @@ def setup_core( CORE.config = config CORE.toolchain = Toolchain.PLATFORMIO - if platform is not None: - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + # Production always populates CORE.data[KEY_CORE] before upload/logs run + # (the platform validator sets it during read_config, and + # StorageJSON.apply_to_core sets it on the cache fast path), so mirror + # that here. Tests that exercise platform-specific behavior pass a + # platform explicitly; the rest get a platform-agnostic None. + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: CORE.config_path = str(tmp_path / f"{name}.yaml") @@ -1660,6 +1663,29 @@ def test_upload_program_serial_platformio_platforms( mock_upload_using_platformio.assert_called_once_with(config, device) +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_serial_unknown_platform( + mock_import: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Serial upload on an unsupported platform falls through to exit_code 1.""" + setup_core(platform="custom_platform") + # Module has no upload_program handler, so the SERIAL branch is reached. + mock_import.return_value = MagicMock(spec=[]) + mock_get_port_type.return_value = "SERIAL" + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + + def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: @@ -6350,6 +6376,44 @@ def test_command_run_defaults_subscribe_states_true( ) +def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: + """After a BOOTSEL upload (no device) on RP2040, command_run waits for and + picks up the newly enumerated serial port before showing logs.""" + setup_core( + config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, + platform=PLATFORM_RP2040, + ) + + args = MockArgs() + args.no_logs = False + args.device = None + + new_port = MockSerialPort("/dev/ttyACM0", "RP2040 Serial") + + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch( + "esphome.__main__.choose_upload_log_host", + side_effect=[[], ["/dev/ttyACM0"]], + ) as mock_choose, + patch("esphome.__main__.upload_program", return_value=(0, None)), + patch( + "esphome.__main__.get_serial_ports", + side_effect=[[], [new_port]], + ), + patch("esphome.__main__._wait_for_serial_port") as mock_wait, + patch("esphome.__main__.show_logs", return_value=0) as mock_show_logs, + ): + result = command_run(args, CORE.config) + + assert result == 0 + mock_wait.assert_called_once_with(known_ports=set()) + # The re-detected serial port is used as the preferred logging device. + assert mock_choose.call_args_list[-1].kwargs["default"] == "/dev/ttyACM0" + mock_show_logs.assert_called_once_with(CORE.config, args, ["/dev/ttyACM0"]) + + def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" setup_core() From eae65a6b881388bf9f478a700e98dd2f9671d117 Mon Sep 17 00:00:00 2001 From: Berik Visschers Date: Tue, 23 Jun 2026 17:44:48 +0200 Subject: [PATCH 124/343] [bme680_bsec][bme68x_bsec2][const] Move BME sensor constants to shared component consts (#17160) --- esphome/components/bme680_bsec/__init__.py | 2 +- esphome/components/bme680_bsec/sensor.py | 8 +++++--- esphome/components/bme68x_bsec2/__init__.py | 2 +- esphome/components/bme68x_bsec2/sensor.py | 8 +++++--- esphome/components/const/__init__.py | 4 ++++ 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 2365f8d1073..e1e01facd01 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32, i2c +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework @@ -12,7 +13,6 @@ MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" CONF_IAQ_MODE = "iaq_mode" CONF_SUPPLY_VOLTAGE = "supply_voltage" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index 8d3ae76e3fa..bdc8d8f2d38 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent DEPENDENCIES = ["bme680_bsec"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 62cd9e2e364..63f63c5da2e 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import core, external_files import esphome.codegen as cg +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -24,7 +25,6 @@ CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" CONF_IAQ_MODE = "iaq_mode" CONF_OPERATING_AGE = "operating_age" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_SUPPLY_VOLTAGE = "supply_voltage" bme68x_bsec2_ns = cg.esphome_ns.namespace("bme68x_bsec2") diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index f21a9b8138e..52587dba99e 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component DEPENDENCIES = ["bme68x_bsec2"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" CONF_IAQ_STATIC = "iaq_static" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 9951243f0dc..85878a6306d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,8 +8,10 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" +CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" +CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" @@ -17,6 +19,7 @@ CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" @@ -28,6 +31,7 @@ CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SHA256 = "sha256" +CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" From e0377bbbd31cd9d043d44e01a721a50fa3fb409f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:23 -0400 Subject: [PATCH 125/343] [ci] Enable ccache for component batch builds (~7% faster) (#17136) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ace8c179e..c4149e20490 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -822,8 +822,8 @@ jobs: - name: Cache apt packages uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 with: - packages: libsdl2-dev - version: 1.0 + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -941,6 +941,11 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi + - name: Print ccache statistics + # esphome stores the cache under the IDF tools path; expand the leading + # ~ in ESPHOME_ESP_IDF_PREFIX so ccache reads the dir the build used. + run: CCACHE_DIR="${ESPHOME_ESP_IDF_PREFIX/#\~/$HOME}/ccache" ccache -s + test-esp32-platformio: name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 From c2d79c972c9d5e64c540e3714e8ae1557ca27d18 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:04:42 -0400 Subject: [PATCH 126/343] [docker] Install ccache in the image (#17157) --- docker/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bf37d6d88bf..1fe380552ae 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -17,8 +17,11 @@ 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. +# ccache speeds up repeat ESP-IDF compiles (enabled via IDF_CCACHE_ENABLE); +# ESP-IDF silently skips it when the binary isn't on PATH, so it must be +# present in the image for the dashboard/Device Builder to benefit. RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 ccache \ && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From ff001b9e4570e229b24c47bd28c37c2e83688f5c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:26 -0400 Subject: [PATCH 127/343] [esp32_ble_server] Fix set_value action with by-reference triggers (#17156) --- .../esp32_ble_server/ble_server_automations.h | 6 ++-- tests/components/esp32_ble_server/common.yaml | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index c6cba14b9b3..e5463847fa8 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -77,13 +77,15 @@ template class BLECharacteristicSetValueAction final : public Ac // Set initial value this->parent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->parent_->on_read([this, x...](uint16_t id) { + // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference + // (e.g. climate on_control's ClimateCall&). See #17142. + this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 4e34049038a..c617a73f871 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -77,3 +77,34 @@ esp32_ble_server: id: test_change_descriptor value: data: [0x01, 0x02, 0x03] + +# Regression test for #17142: the set_value action used from a trigger that passes +# its argument by reference (climate on_control supplies ClimateCall&) previously +# failed to compile. +sensor: + - platform: template + id: ble_test_temp + lambda: "return 20.0;" + +output: + - platform: template + id: ble_test_output + type: float + write_action: + - logger.log: "out" + +climate: + - platform: pid + name: "BLE Test Climate" + id: ble_test_climate + sensor: ble_test_temp + default_target_temperature: 20 + heat_output: ble_test_output + control_parameters: + kp: 0.1 + ki: 0.001 + kd: 0.1 + on_control: + - ble_server.characteristic.set_value: + id: test_notify_characteristic + value: !lambda "return std::vector{0, 1, 2};" From 7763ce958d3cd92eb5b0b7348976d42323efcbdb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:40 -0400 Subject: [PATCH 128/343] [tests] Disable Hypothesis deadline on IP validation property tests (#17138) --- tests/unit_tests/test_config_validation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 74d9a5047ab..f1a61188705 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,6 +1,6 @@ import string -from hypothesis import example, given +from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol @@ -276,6 +276,10 @@ def test_boolean__invalid(value): config_validation.boolean(value) +# deadline disabled: the validator is trivially fast, but Hypothesis's per-example +# deadline can spuriously trip on slow/loaded CI runners (e.g. one example hitting +# a GC pause), making this a flaky failure. Matches test_helpers.py. +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): config_validation.ipv4address(value) @@ -287,6 +291,7 @@ def test_ipv4__invalid(value): config_validation.ipv4address(value) +@settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): config_validation.ipaddress(value) From e3b644c2a0d85fd58e6ce3a5d48119bc8548dd60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:16 -0500 Subject: [PATCH 129/343] Bump actions/cache from 5.0.5 to 6.0.0 in /.github/actions/restore-python (#17169) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 66d016b42d8..96a3be53c60 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv # yamllint disable-line rule:line-length From a24a63e61b588a00aceb325f9e12c73ae8534edb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:28 -0500 Subject: [PATCH 130/343] Bump actions/cache from 5.0.5 to 6.0.0 (#17168) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4149e20490..0a7233b3a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv # yamllint disable-line rule:line-length @@ -250,7 +250,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -295,7 +295,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -339,7 +339,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -365,7 +365,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -509,14 +509,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -1098,7 +1098,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1122,7 +1122,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1164,7 +1164,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1211,7 +1211,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 49536693b71e9f9a5cd1cd81ccf0478414e2befd Mon Sep 17 00:00:00 2001 From: mnewton25 <83018731+mnewton25@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:40:49 +0100 Subject: [PATCH 131/343] [esp32] Use POSIX path for secure-boot signing/verification keys Fixes #17164 (#17166) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8ba1ac4608d..945eda3912e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2368,14 +2368,14 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_SIGNING_KEY", - str(signed_ota[CONF_SIGNING_KEY].resolve()), + signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: # Public key mode — verification only, external signing required add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - str(signed_ota[CONF_VERIFICATION_KEY].resolve()), + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") From 1d32b6c9e0789f9662b021e8a9d351e2edbf181e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:41:13 -0400 Subject: [PATCH 132/343] [espidf] Enable ccache by default for ESP-IDF builds (#17163) --- esphome/espidf/framework.py | 55 +++++++++++++- tests/unit_tests/test_espidf_framework.py | 87 +++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 4053898a8e5..f0715ce3b22 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -23,7 +23,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) -from esphome.helpers import get_str_env, write_file_if_changed +from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -814,6 +814,56 @@ def check_esp_idf_install( return framework_path, python_env_path +def _ccache_env() -> dict[str, str]: + """Return ccache settings for ESP-IDF compiles. + + Enabled by default whenever the ``ccache`` binary is on PATH; set + ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under + the IDF tools path. How widely it is shared depends on where that resolves: + across projects (and surviving ``clean-all``) when it is a common location + (``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under + ``.esphome/idf`` for a default pip install, where ``clean-all`` clears it + along with the framework. + + Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles + instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build + absolute paths (generated ``sdkconfig`` include, etc.) so different devices + share framework cache entries; it is scoped to the build dir on purpose -- + a broader base would also rewrite the shared IDF path under the cache dir + and lose those hits. + + Only values the user has not already set in the environment are returned, so + a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected. + """ + # Honor an explicit choice already in the environment (opt-out or opt-in). + if "IDF_CCACHE_ENABLE" in os.environ: + if not get_bool_env("IDF_CCACHE_ENABLE"): + return {} + elif shutil.which("ccache") is None: + # ESP-IDF silently skips ccache without the binary; don't enable it. + return {} + + # ccache is enabled past here. build_path is set during preload for every + # config-loading command, so it being unset means a caller built the IDF env + # too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which + # would quietly cost cross-device cache hits). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the ESP-IDF build " + "environment" + ) + + defaults = { + "IDF_CCACHE_ENABLE": "1", + "CCACHE_DIR": str(_get_idf_tools_path() / "ccache"), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + # Don't override CCACHE_* values the user already set in their environment. + return {k: v for k, v in defaults.items() if k not in os.environ} + + def get_framework_env( framework_path: PathType, python_env_path: PathType | None = None, @@ -856,4 +906,7 @@ def get_framework_env( env.update(export_vars) env["PATH"] = os.pathsep.join(paths_to_export + path_list) + # 6. Enable ccache for the compile toolchain (default on when available). + env.update(_ccache_env()) + return env diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 525cd55146f..b5fa0e26980 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -15,6 +15,7 @@ from unittest.mock import patch import pytest from esphome.espidf.framework import ( + _ccache_env, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, @@ -620,6 +621,8 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: "esphome.espidf.framework._get_idf_tool_paths", return_value=(["/tool/bin"], {"IDF_X": "1"}), ), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env( tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} @@ -640,6 +643,8 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env(tmp_path / "fw") @@ -647,6 +652,88 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No assert env["PATH"] # taken from os.environ +# --------------------------------------------------------------------------- +# _ccache_env +# --------------------------------------------------------------------------- + + +def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): + return ( + patch("esphome.espidf.framework.shutil.which", return_value=which), + patch( + "esphome.espidf.framework._get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch( + "esphome.espidf.framework.CORE", + SimpleNamespace(build_path=build_path), + ), + ) + + +def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + env = _ccache_env() + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: + # build_path is None here too: a disabled cache must not require it. + p1, p2, p3 = _ccache_patches(tmp_path, None, None) + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=0 wins even when the binary is present, and + # short-circuits before build_path is needed. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's + # already in the environment, so it isn't re-emitted, but the rest is. + p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: + env = _ccache_env() + assert "IDF_CCACHE_ENABLE" not in env + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: + # User-set CCACHE_* values must not be clobbered; unset ones still default. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + user_env = {"CCACHE_DIR": "/my/cache", "CCACHE_MAXSIZE": "9G"} + with patch.dict("os.environ", user_env, clear=True), p1, p2, p3: + env = _ccache_env() + assert "CCACHE_DIR" not in env + assert "CCACHE_MAXSIZE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: + # Enabled but no build_path means the IDF env was built too early -- fail + # loudly instead of silently dropping CCACHE_BASEDIR. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with ( + patch.dict("os.environ", {}, clear=True), + p1, + p2, + p3, + pytest.raises(ValueError, match="build_path"), + ): + _ccache_env() + + # --------------------------------------------------------------------------- # _check_stamp / _write_idf_version_txt / _get_idf_tools_path # --------------------------------------------------------------------------- From 84de814e6f7236696da9787ac027e21b2aaf80db Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:05:39 +1200 Subject: [PATCH 133/343] [config_validation] Make bind_key a sensitive dual-mode validator (#17146) --- esphome/components/dlms_meter/__init__.py | 8 +- esphome/components/dsmr/__init__.py | 4 +- esphome/config_validation.py | 67 +++++++++++++---- tests/unit_tests/test_config_validation.py | 86 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 7094699b0bd..b747f73a14b 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -136,12 +136,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), - cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( - value, name="Authentication key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), + cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, cv.Optional(CONF_PROVIDER): cv.string, diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 1dc36646026..34f37ace35f 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Dsmr), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), cv.Optional(CONF_CRC_CHECK, default=True): cv.boolean, cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0ef6d212fe5..0fdce85dc31 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1220,21 +1220,60 @@ def mac_address(value): return core.MACAddress(*parts_int) -def bind_key(value, *, name="Bind key"): - value = string_strict(value) - parts = [value[i : i + 2] for i in range(0, len(value), 2)] - if len(parts) != 16: - raise Invalid(f"{name} must consist of 16 hexadecimal numbers") - parts_int = [] - if any(len(part) != 2 for part in parts): - raise Invalid(f"{name} must be format XX") - for part in parts: - try: - parts_int.append(int(part, 16)) - except ValueError: - raise Invalid(f"{name} must be hex values from 00 to FF") from None +_BIND_KEY_MISSING = object() - return "".join(f"{part:02X}" for part in parts_int) + +class BindKeyValidator(SensitiveValidator): + """Sensitive validator for a 16-byte hex bind/encryption key. + + Use bare as a validator (``cv.bind_key``) for the default error wording, or + call it with a custom ``name`` (``cv.bind_key(name="Decryption key")``) to + get a validator with tailored error messages. Either way the value is marked + sensitive so frontends mask it and dump tooling redacts it. + """ + + def __init__(self, name: str = "Bind key") -> None: + self._name = name + super().__init__(self._validate) + + def _validate(self, value: typing.Any) -> str: + value = string_strict(value) + parts = [value[i : i + 2] for i in range(0, len(value), 2)] + if len(parts) != 16: + raise Invalid(f"{self._name} must consist of 16 hexadecimal numbers") + parts_int = [] + if any(len(part) != 2 for part in parts): + raise Invalid(f"{self._name} must be format XX") + for part in parts: + try: + parts_int.append(int(part, 16)) + except ValueError: + raise Invalid( + f"{self._name} must be hex values from 00 to FF" + ) from None + + return "".join(f"{part:02X}" for part in parts_int) + + def __call__( + self, value: typing.Any = _BIND_KEY_MISSING, *, name: str | None = None + ) -> typing.Any: + if value is _BIND_KEY_MISSING: + # Factory usage: return a validator with customized error wording. + return BindKeyValidator(name if name is not None else self._name) + if name is not None and name != self._name: + # Direct validation with a one-off custom name. + return BindKeyValidator(name)(value) + return super().__call__(value) + + def __repr__(self) -> str: + # ``self.inner`` is a bound method of this instance, so the inherited + # ``SensitiveValidator.__repr__`` (which returns ``repr(self.inner)``) + # would recurse infinitely. Provide a stable, name-keyed repr instead so + # ``build_language_schema`` dedup and voluptuous errors stay sane. + return f"bind_key({self._name!r})" + + +bind_key = BindKeyValidator() def uuid(value): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f1a61188705..9b9f003b0d4 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -188,6 +188,92 @@ def test_sensitive__is_detectable_via_isinstance() -> None: assert isinstance(validator, config_validation.SensitiveValidator) +def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: + # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for + # frontend masking and validating a value directly tags the result. + assert isinstance(config_validation.bind_key, config_validation.SensitiveValidator) + + result = config_validation.bind_key("0123456789ABCDEF0123456789ABCDEF") + + assert isinstance(result, SensitiveStr) + assert result == "0123456789ABCDEF0123456789ABCDEF" + + +def test_bind_key__bare_usage_in_schema() -> None: + # Voluptuous calls the bare validator with the config value; the result must + # come through tagged sensitive. + schema = config_validation.Schema( + {config_validation.Required("key"): config_validation.bind_key} + ) + out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) + + assert isinstance(out["key"], SensitiveStr) + + +def test_bind_key__factory_returns_sensitive_validator() -> None: + # Called with a name (cv.bind_key(name=...)) it returns a new sensitive + # validator rather than validating. + validator = config_validation.bind_key(name="Decryption key") + + assert isinstance(validator, config_validation.SensitiveValidator) + assert validator is not config_validation.bind_key + assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) + + +@pytest.mark.parametrize( + ("value", "error"), + ( + ("00", "Decryption key must consist of 16 hexadecimal numbers"), + ("0123456789ABCDEF0123456789ABCDEG", "Decryption key must be hex values"), + ), +) +def test_bind_key__custom_name_in_error(value: str, error: str) -> None: + # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. + validator = config_validation.bind_key(name="Decryption key") + with pytest.raises(Invalid, match=error): + validator(value) + + +def test_bind_key__rejects_non_hex_pair_length() -> None: + # Odd-length input yields a trailing single-char part, hitting the + # "format XX" branch rather than the hex-value branch. + with pytest.raises(Invalid, match="Bind key must be format XX"): + config_validation.bind_key("0123456789ABCDEF0123456789ABCDE") + + +def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: + # Passing both a value and a name validates immediately using the custom + # name for error wording, and still tags the result sensitive. + result = config_validation.bind_key( + "0123456789ABCDEF0123456789ABCDEF", name="Decryption key" + ) + assert isinstance(result, SensitiveStr) + + with pytest.raises(Invalid, match="Decryption key must consist of"): + config_validation.bind_key("00", name="Decryption key") + + +def test_bind_key__factory_without_name_keeps_existing_name() -> None: + # Re-invoking a named validator without a name preserves its name rather + # than resetting to the default. + named = config_validation.bind_key(name="Decryption key") + rederived = named() + + with pytest.raises(Invalid, match="Decryption key must consist of"): + rederived("00") + + +def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: + # ``self.inner`` is a bound method of the instance, so the inherited + # ``repr(self.inner)`` would recurse infinitely; the override keeps repr + # finite and keyed on the name for schema-dump dedup. + assert repr(config_validation.bind_key) == "bind_key('Bind key')" + assert ( + repr(config_validation.bind_key(name="Decryption key")) + == "bind_key('Decryption key')" + ) + + def test_sensitive__repr_mirrors_inner() -> None: # The schema dump dedups on ``repr(schema)``; mirroring the inner # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers From 344da7c4f4a8a2e32478792a26647a5511392a90 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:35:07 -0400 Subject: [PATCH 134/343] [docker] Move build deps to base image, drop app apt step (#17167) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- docker/Dockerfile | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1fe380552ae..c1baa51ae34 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2026.06.0 +ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -11,19 +11,6 @@ FROM base-source-${BUILD_TYPE} AS base RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false -# Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). -# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can -# 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. -# ccache speeds up repeat ESP-IDF compiles (enabled via IDF_CCACHE_ENABLE); -# ESP-IDF silently skips it when the binary isn't on PATH, so it must be -# present in the image for the dashboard/Device Builder to benefit. -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 ccache \ - && rm -rf /var/lib/apt/lists/* - ENV PIP_DISABLE_PIP_VERSION_CHECK=1 RUN pip install --no-cache-dir -U pip uv==0.10.1 From 72686bd4aff439b5b303b09b1b9940bc9126937b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:43:39 +1000 Subject: [PATCH 135/343] [mipi_spi] Warn on MODE3 default for display without CS pin (#17153) --- esphome/components/mipi_spi/display.py | 10 ++++- tests/component_tests/mipi_spi/test_init.py | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index abb7eaa4585..d613d0a1ab0 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -172,13 +172,19 @@ def model_schema(config): if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. - spi_mode = model.get_default(CONF_SPI_MODE) + spi_mode = ( + cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE) + ) if not spi_mode: if bus_mode == TYPE_OCTAL or ( bus_mode == TYPE_SINGLE - and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False ): spi_mode = "MODE3" + if bus_mode == TYPE_SINGLE: + LOGGER.warning( + "No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3." + ) else: spi_mode = "MODE0" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index d681908027d..dbd8e15702e 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -306,6 +306,50 @@ def test_all_predefined_models( run_schema_validation(config) +def test_single_bus_no_cs_no_mode_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation({"model": "ili9488", "dc_pin": 14}) + + assert "defaulting to MODE3 due to lack of CS pin" in caplog.text + + +@pytest.mark.parametrize( + "config", + [ + pytest.param( + {"model": "ili9488", "dc_pin": 14, "cs_pin": 0}, + id="cs_pin_provided", + ), + pytest.param( + {"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"}, + id="spi_mode_provided", + ), + ], +) +def test_single_bus_no_mode_warning_suppressed( + config: ConfigType, + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No MODE3 warning when a CS pin or an explicit SPI mode is provided.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + run_schema_validation(config) + + assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text + + def test_native_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], From dae078fc56a8350c6260997af4fcddada4589e21 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:06:15 +1200 Subject: [PATCH 136/343] Bump bundled esphome-device-builder to 1.0.15 (#17170) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c1baa51ae34..1cd33722550 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.14 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 RUN \ platformio settings set enable_telemetry No \ From 2b8916fc4e40f33ad908e7d2c2bfa0c3159edfb6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:13:34 +1200 Subject: [PATCH 137/343] [ci] Exclude test changes from small-pr/medium-pr size labels (#17172) --- .github/scripts/auto-label-pr/detectors.js | 31 +++++--- .github/scripts/auto-label-pr/index.js | 2 +- .../auto-label-pr/tests/detectors.test.js | 78 ++++++++++++++++++- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 81bb77843d1..4406370a274 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -147,19 +147,9 @@ async function detectCoreChanges(changedFiles) { } // Strategy: PR size detection -async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { +async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { const labels = new Set(); - if (totalChanges <= SMALL_PR_THRESHOLD) { - labels.add('small-pr'); - return labels; - } - - if (totalChanges <= MEDIUM_PR_THRESHOLD) { - labels.add('medium-pr'); - return labels; - } - const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); @@ -167,7 +157,24 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.deletions || 0), 0); - const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + const nonTestAdditions = totalAdditions - testAdditions; + const nonTestDeletions = totalDeletions - testDeletions; + + // small/medium count churn (additions + deletions) so a balanced refactor isn't undersized. + const nonTestChurn = nonTestAdditions + nonTestDeletions; + + if (nonTestChurn <= SMALL_PR_THRESHOLD) { + labels.add('small-pr'); + return labels; + } + + if (nonTestChurn <= MEDIUM_PR_THRESHOLD) { + labels.add('medium-pr'); + return labels; + } + + // too-big uses net line delta (additions - deletions), matching the review message in reviews.js. + const nonTestChanges = nonTestAdditions - nonTestDeletions; // Don't add too-big if mega-pr label is already present if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 9769cd80601..c8bdcfb2f38 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -123,7 +123,7 @@ module.exports = async ({ github, context }) => { detectNewComponents(github, context, prFiles), detectNewPlatforms(github, context, prFiles, apiData), detectCoreChanges(changedFiles), - detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectDashboardChanges(changedFiles), detectGitHubActionsChanges(changedFiles), detectCodeOwner(github, context, changedFiles), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index 02d69ca95ea..aab1827c44e 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { detectNewPlatforms, detectNewComponents } = require('../detectors'); +const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // to check for CONFIG_SCHEMA in newly added files. @@ -145,3 +145,79 @@ describe('detectNewComponents', () => { assert.equal(result.labels.size, 0); }); }); + +// --------------------------------------------------------------------------- +// detectPRSize +// --------------------------------------------------------------------------- + +describe('detectPRSize', () => { + const SMALL = 30; + const MEDIUM = 100; + const TOO_BIG = 1000; + + function size(prFiles, isMegaPR = false) { + const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); + const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); + return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG); + } + + it('counts only non-test changes toward small-pr', async () => { + // 10 source + 5000 test lines -> non-test churn of 10 is still small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('small-pr')); + assert.equal(labels.size, 1); + }); + + it('counts additions and deletions as churn (not net delta)', async () => { + // A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('labels medium-pr when non-test changes exceed small threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('uses net delta (not churn) for too-big', async () => { + // 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 }, + ]); + assert.equal(labels.size, 0); + }); + + it('labels too-big when non-test changes exceed the big threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('too-big')); + assert.equal(labels.size, 1); + }); + + it('does not label too-big when mega-pr is set', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); + + it('produces no size label for a large mega-pr in the gap above medium', async () => { + // Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); +}); From e6455c5b448296f3e99de5e50deda6702f0b9ced Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:27:07 +1200 Subject: [PATCH 138/343] Mark configurable classes as final (12/21: msa3xx-pm2005) (#16963) --- esphome/components/msa3xx/msa3xx.h | 2 +- esphome/components/my9231/my9231.h | 4 ++-- esphome/components/nau7802/nau7802.h | 8 ++++---- esphome/components/network/network_component.h | 2 +- esphome/components/nextion/automation.h | 8 ++++---- .../nextion/binary_sensor/nextion_binarysensor.h | 6 +++--- esphome/components/nextion/nextion.h | 2 +- esphome/components/nextion/sensor/nextion_sensor.h | 2 +- esphome/components/nextion/switch/nextion_switch.h | 2 +- .../nextion/text_sensor/nextion_textsensor.h | 2 +- esphome/components/nfc/automation.h | 2 +- .../components/nfc/binary_sensor/nfc_binary_sensor.h | 8 ++++---- esphome/components/noblex/noblex.h | 2 +- esphome/components/npi19/npi19.h | 2 +- esphome/components/nrf52/dfu.h | 2 +- esphome/components/ntc/ntc.h | 2 +- esphome/components/number/automation.h | 10 +++++----- esphome/components/number/sensor/number_sensor.h | 2 +- esphome/components/online_image/online_image.h | 10 +++++----- esphome/components/opentherm/automation.h | 4 ++-- esphome/components/opentherm/hub.h | 2 +- esphome/components/opentherm/number/opentherm_number.h | 2 +- esphome/components/opentherm/output/opentherm_output.h | 2 +- esphome/components/opentherm/switch/opentherm_switch.h | 2 +- esphome/components/openthread/openthread.h | 4 ++-- esphome/components/opt3001/opt3001.h | 2 +- esphome/components/output/automation.h | 10 +++++----- esphome/components/output/button/output_button.h | 2 +- esphome/components/output/lock/output_lock.h | 2 +- esphome/components/output/switch/output_switch.h | 2 +- esphome/components/partition/light_partition.h | 2 +- esphome/components/pca6416a/pca6416a.h | 8 ++++---- esphome/components/pca9554/pca9554.h | 8 ++++---- esphome/components/pca9685/pca9685_output.h | 4 ++-- esphome/components/pcd8544/pcd_8544.h | 6 +++--- esphome/components/pcf85063/pcf85063.h | 6 +++--- esphome/components/pcf8563/pcf8563.h | 6 +++--- esphome/components/pcf8574/pcf8574.h | 8 ++++---- esphome/components/pcm5122/pcm5122.h | 2 +- esphome/components/pcm5122/pcm5122_gpio.h | 2 +- esphome/components/pi4ioe5v6408/pi4ioe5v6408.h | 8 ++++---- esphome/components/pid/pid_climate.h | 8 ++++---- esphome/components/pid/sensor/pid_climate_sensor.h | 2 +- esphome/components/pipsolar/output/pipsolar_output.h | 4 ++-- esphome/components/pipsolar/pipsolar.h | 2 +- esphome/components/pipsolar/switch/pipsolar_switch.h | 2 +- esphome/components/pm1006/pm1006.h | 2 +- esphome/components/pm2005/pm2005.h | 2 +- 48 files changed, 97 insertions(+), 97 deletions(-) diff --git a/esphome/components/msa3xx/msa3xx.h b/esphome/components/msa3xx/msa3xx.h index 345afc50abb..212ee10a48c 100644 --- a/esphome/components/msa3xx/msa3xx.h +++ b/esphome/components/msa3xx/msa3xx.h @@ -211,7 +211,7 @@ union RegTapDuration { uint8_t raw{0x04}; }; -class MSA3xxComponent : public PollingComponent, public i2c::I2CDevice { +class MSA3xxComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index 60b113079ef..ababfd7fc7a 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -8,7 +8,7 @@ namespace esphome::my9231 { /// MY9231 float output component. -class MY9231OutputComponent : public Component { +class MY9231OutputComponent final : public Component { public: class Channel; void set_pin_di(GPIOPin *pin_di) { pin_di_ = pin_di; } @@ -26,7 +26,7 @@ class MY9231OutputComponent : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(MY9231OutputComponent *parent) { parent_ = parent; } void set_channel(uint16_t channel) { channel_ = channel; } diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index 67f36ca6772..c53a018234d 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -47,7 +47,7 @@ enum NAU7802CalibrationModes { NAU7802_CALIBRATE_GAIN = 0b11, }; -class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_samples_per_second(NAU7802SPS sps) { this->sps_ = sps; } void set_ldo_voltage(NAU7802LDO ldo) { this->ldo_ = ldo; } @@ -97,18 +97,18 @@ class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c }; template -class NAU7802CalbrateExternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } }; template -class NAU7802CalbrateInternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } }; -template class NAU7802CalbrateGainAction : public Action, public Parented { +template class NAU7802CalbrateGainAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_gain(); } }; diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index dde15940e40..2e76a956738 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,7 +4,7 @@ #include "esphome/core/component.h" namespace esphome::network { -class NetworkComponent : public Component { +class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index e039dae6157..0226c65be6c 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -7,7 +7,7 @@ namespace esphome::nextion { -template class NextionSetBrightnessAction : public Action { +template class NextionSetBrightnessAction final : public Action { public: explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} @@ -24,7 +24,7 @@ template class NextionSetBrightnessAction : public Action Nextion *component_; }; -template class NextionPublishFloatAction : public Action { +template class NextionPublishFloatAction final : public Action { public: explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} @@ -47,7 +47,7 @@ template class NextionPublishFloatAction : public Action NextionComponent *component_; }; -template class NextionPublishTextAction : public Action { +template class NextionPublishTextAction final : public Action { public: explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} @@ -70,7 +70,7 @@ template class NextionPublishTextAction : public Action { NextionComponent *component_; }; -template class NextionPublishBoolAction : public Action { +template class NextionPublishBoolAction final : public Action { public: explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index 7637957222d..9970db1c01a 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -8,9 +8,9 @@ namespace esphome::nextion { class NextionBinarySensor; -class NextionBinarySensor : public NextionComponent, - public binary_sensor::BinarySensorInitiallyOff, - public PollingComponent { +class NextionBinarySensor final : public NextionComponent, + public binary_sensor::BinarySensorInitiallyOff, + public PollingComponent { public: NextionBinarySensor(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index ef030e71da4..d361d9725b4 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -76,7 +76,7 @@ class NextionCommandPacer { }; #endif // USE_NEXTION_COMMAND_SPACING -class Nextion : public NextionBase, public PollingComponent, public uart::UARTDevice { +class Nextion final : public NextionBase, public PollingComponent, public uart::UARTDevice { public: #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP /** diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index 72e3982b3a1..bc0875fdff9 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSensor; -class NextionSensor : public NextionComponent, public sensor::Sensor, public PollingComponent { +class NextionSensor final : public NextionComponent, public sensor::Sensor, public PollingComponent { public: NextionSensor(NextionBase *nextion) { this->nextion_ = nextion; } void send_state_to_nextion() override { this->set_state(this->state, false, true); }; diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index 7e0593d217c..2cac733b496 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSwitch; -class NextionSwitch : public NextionComponent, public switch_::Switch, public PollingComponent { +class NextionSwitch final : public NextionComponent, public switch_::Switch, public PollingComponent { public: NextionSwitch(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 42cd5dcef4f..5ef2bb222fa 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionTextSensor; -class NextionTextSensor : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { +class NextionTextSensor final : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { public: NextionTextSensor(NextionBase *nextion) { this->nextion_ = nextion; } void update() override; diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index 0ac3e3b8b67..ec3a979b647 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -7,7 +7,7 @@ namespace esphome::nfc { -class NfcOnTagTrigger : public Trigger { +class NfcOnTagTrigger final : public Trigger { public: void process(const std::unique_ptr &tag); }; diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index b3448a57ccd..6354e169670 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -8,10 +8,10 @@ namespace esphome::nfc { -class NfcTagBinarySensor : public binary_sensor::BinarySensor, - public Component, - public NfcTagListener, - public Parented { +class NfcTagBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public NfcTagListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/noblex/noblex.h b/esphome/components/noblex/noblex.h index 62070e5deed..e505a5ba3f4 100644 --- a/esphome/components/noblex/noblex.h +++ b/esphome/components/noblex/noblex.h @@ -8,7 +8,7 @@ namespace esphome::noblex { const uint8_t NOBLEX_TEMP_MIN = 16; // Celsius const uint8_t NOBLEX_TEMP_MAX = 30; // Celsius -class NoblexClimate : public climate_ir::ClimateIR { +class NoblexClimate final : public climate_ir::ClimateIR { public: NoblexClimate() : climate_ir::ClimateIR(NOBLEX_TEMP_MIN, NOBLEX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/npi19/npi19.h b/esphome/components/npi19/npi19.h index d1f74141ac7..f18a0989de6 100644 --- a/esphome/components/npi19/npi19.h +++ b/esphome/components/npi19/npi19.h @@ -7,7 +7,7 @@ namespace esphome::npi19 { /// This class implements support for the npi19 pressure and temperature i2c sensors. -class NPI19Component : public PollingComponent, public i2c::I2CDevice { +class NPI19Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h index 82c7d9f54eb..4f7ad89b183 100644 --- a/esphome/components/nrf52/dfu.h +++ b/esphome/components/nrf52/dfu.h @@ -6,7 +6,7 @@ #include "esphome/core/gpio.h" namespace esphome::nrf52 { -class DeviceFirmwareUpdate : public Component { +class DeviceFirmwareUpdate final : public Component { public: void setup() override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } diff --git a/esphome/components/ntc/ntc.h b/esphome/components/ntc/ntc.h index 466d03f789f..25fbf3c85d5 100644 --- a/esphome/components/ntc/ntc.h +++ b/esphome/components/ntc/ntc.h @@ -5,7 +5,7 @@ namespace esphome::ntc { -class NTC : public Component, public sensor::Sensor { +class NTC final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_a(double a) { a_ = a; } diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 2843aa6bf5e..4efcfd30d8e 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,14 +6,14 @@ namespace esphome::number { -class NumberStateTrigger : public Trigger { +class NumberStateTrigger final : public Trigger { public: explicit NumberStateTrigger(Number *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -template class NumberSetAction : public Action { +template class NumberSetAction final : public Action { public: NumberSetAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(float, value) @@ -28,7 +28,7 @@ template class NumberSetAction : public Action { Number *number_; }; -template class NumberOperationAction : public Action { +template class NumberOperationAction final : public Action { public: explicit NumberOperationAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(NumberOperation, operation) @@ -47,7 +47,7 @@ template class NumberOperationAction : public Action { Number *number_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} @@ -67,7 +67,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class NumberInRangeCondition : public Condition { +template class NumberInRangeCondition final : public Condition { public: NumberInRangeCondition(Number *parent) : parent_(parent) {} diff --git a/esphome/components/number/sensor/number_sensor.h b/esphome/components/number/sensor/number_sensor.h index 2d6825a2989..ba3cec150cb 100644 --- a/esphome/components/number/sensor/number_sensor.h +++ b/esphome/components/number/sensor/number_sensor.h @@ -6,7 +6,7 @@ namespace esphome::number { -class NumberSensor : public sensor::Sensor, public Component { +class NumberSensor final : public sensor::Sensor, public Component { public: explicit NumberSensor(Number *source) : source_(source) {} void setup() override; diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index a967bb6c0e6..3e386f8cc8e 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -21,9 +21,9 @@ using t_http_codes = enum { * The image will then be stored in a buffer, so that it can be re-displayed without the * need to re-download or re-decode. */ -class OnlineImage : public PollingComponent, - public runtime_image::RuntimeImage, - public Parented { +class OnlineImage final : public PollingComponent, + public runtime_image::RuntimeImage, + public Parented { public: /** * @brief Construct a new OnlineImage object. @@ -104,7 +104,7 @@ class OnlineImage : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction : public Action { +template class OnlineImageSetUrlAction final : public Action { public: OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) @@ -120,7 +120,7 @@ template class OnlineImageSetUrlAction : public Action { OnlineImage *parent_; }; -template class OnlineImageReleaseAction : public Action { +template class OnlineImageReleaseAction final : public Action { public: OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->release(); } diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h index aa20a4ec5a4..365992b2805 100644 --- a/esphome/components/opentherm/automation.h +++ b/esphome/components/opentherm/automation.h @@ -6,14 +6,14 @@ namespace esphome::opentherm { -class BeforeSendTrigger : public Trigger { +class BeforeSendTrigger final : public Trigger { public: BeforeSendTrigger(OpenthermHub *hub) { hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); } }; -class BeforeProcessResponseTrigger : public Trigger { +class BeforeProcessResponseTrigger final : public Trigger { public: BeforeProcessResponseTrigger(OpenthermHub *hub) { hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index 26381376686..268c6210f01 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -41,7 +41,7 @@ static const uint8_t REPEATING_MESSAGE_ORDER = 255; static const uint8_t INITIAL_UNORDERED_MESSAGE_ORDER = 254; // OpenTherm component for ESPHome -class OpenthermHub : public Component { +class OpenthermHub final : public Component { protected: // Communication pins for the OpenTherm interface InternalGPIOPin *in_pin_, *out_pin_; diff --git a/esphome/components/opentherm/number/opentherm_number.h b/esphome/components/opentherm/number/opentherm_number.h index c110bed2eb2..c97692ce2a9 100644 --- a/esphome/components/opentherm/number/opentherm_number.h +++ b/esphome/components/opentherm/number/opentherm_number.h @@ -8,7 +8,7 @@ namespace esphome::opentherm { // Just a simple number, which stores the number -class OpenthermNumber : public number::Number, public Component, public OpenthermInput { +class OpenthermNumber final : public number::Number, public Component, public OpenthermInput { protected: void control(float value) override; void setup() override; diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index e789d727022..24d50520764 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { +class OpenthermOutput final : public output::FloatOutput, public Component, public OpenthermInput { protected: bool has_state_ = false; const char *id_ = nullptr; diff --git a/esphome/components/opentherm/switch/opentherm_switch.h b/esphome/components/opentherm/switch/opentherm_switch.h index ca930d4f7c1..235bc234011 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.h +++ b/esphome/components/opentherm/switch/opentherm_switch.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermSwitch : public switch_::Switch, public Component { +class OpenthermSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f1c79fb9cbd..488aad11662 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,7 +19,7 @@ namespace esphome::openthread { class InstanceLock; -class OpenThreadComponent : public Component { +class OpenThreadComponent final : public Component { public: OpenThreadComponent(); ~OpenThreadComponent(); @@ -68,7 +68,7 @@ class OpenThreadComponent : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class OpenThreadSrpComponent : public Component { +class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise diff --git a/esphome/components/opt3001/opt3001.h b/esphome/components/opt3001/opt3001.h index e5de5363537..92f5136bf71 100644 --- a/esphome/components/opt3001/opt3001.h +++ b/esphome/components/opt3001/opt3001.h @@ -7,7 +7,7 @@ namespace esphome::opt3001 { /// This class implements support for the i2c-based OPT3001 ambient light sensor. -class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class OPT3001Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 301f568388a..efe775ba57d 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -8,7 +8,7 @@ namespace esphome::output { -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: TurnOffAction(BinaryOutput *output) : output_(output) {} @@ -18,7 +18,7 @@ template class TurnOffAction : public Action { BinaryOutput *output_; }; -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: TurnOnAction(BinaryOutput *output) : output_(output) {} @@ -28,7 +28,7 @@ template class TurnOnAction : public Action { BinaryOutput *output_; }; -template class SetLevelAction : public Action { +template class SetLevelAction final : public Action { public: SetLevelAction(FloatOutput *output) : output_(output) {} @@ -41,7 +41,7 @@ template class SetLevelAction : public Action { }; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction : public Action { +template class SetMinPowerAction final : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -53,7 +53,7 @@ template class SetMinPowerAction : public Action { FloatOutput *output_; }; -template class SetMaxPowerAction : public Action { +template class SetMaxPowerAction final : public Action { public: SetMaxPowerAction(FloatOutput *output) : output_(output) {} diff --git a/esphome/components/output/button/output_button.h b/esphome/components/output/button/output_button.h index 1a2997bdcf9..bf6be8afe10 100644 --- a/esphome/components/output/button/output_button.h +++ b/esphome/components/output/button/output_button.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputButton : public button::Button, public Component { +class OutputButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/output/lock/output_lock.h b/esphome/components/output/lock/output_lock.h index 7be96e1e824..8e5f4ff7df1 100644 --- a/esphome/components/output/lock/output_lock.h +++ b/esphome/components/output/lock/output_lock.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputLock : public lock::Lock, public Component { +class OutputLock final : public lock::Lock, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/output/switch/output_switch.h b/esphome/components/output/switch/output_switch.h index b0d85678be0..878104f14cf 100644 --- a/esphome/components/output/switch/output_switch.h +++ b/esphome/components/output/switch/output_switch.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputSwitch : public switch_::Switch, public Component { +class OutputSwitch final : public switch_::Switch, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index 7a2f3678c18..adadde068cb 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -31,7 +31,7 @@ class AddressableSegment { bool reversed_; }; -class PartitionLightOutput : public light::AddressableLight { +class PartitionLightOutput final : public light::AddressableLight { public: explicit PartitionLightOutput(std::vector segments) : segments_(std::move(segments)) { int32_t off = 0; diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 3170033b286..39011d53ab7 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -7,9 +7,9 @@ namespace esphome::pca6416a { -class PCA6416AComponent : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA6416AComponent final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA6416AComponent() = default; @@ -49,7 +49,7 @@ class PCA6416AComponent : public Component, }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin : public GPIOPin { +class PCA6416AGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 9fa398cf293..05e945d1763 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -7,9 +7,9 @@ namespace esphome::pca9554 { -class PCA9554Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA9554Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA9554Component() = default; @@ -53,7 +53,7 @@ class PCA9554Component : public Component, }; /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin : public GPIOPin { +class PCA9554GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 33819f23ee0..dad722888f1 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -24,7 +24,7 @@ inline constexpr uint8_t PCA9685_MODE_OUTNE_LOW = 0x01; class PCA9685Output; -class PCA9685Channel : public output::FloatOutput { +class PCA9685Channel final : public output::FloatOutput { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(PCA9685Output *parent) { parent_ = parent; } @@ -39,7 +39,7 @@ class PCA9685Channel : public output::FloatOutput { }; /// PCA9685 float output component. -class PCA9685Output : public Component, public i2c::I2CDevice { +class PCA9685Output final : public Component, public i2c::I2CDevice { public: PCA9685Output(uint8_t mode = PCA9685_MODE_OUTPUT_ONACK | PCA9685_MODE_OUTPUT_TOTEM_POLE) : mode_(mode) {} diff --git a/esphome/components/pcd8544/pcd_8544.h b/esphome/components/pcd8544/pcd_8544.h index 9e4ee930356..3368c395513 100644 --- a/esphome/components/pcd8544/pcd_8544.h +++ b/esphome/components/pcd8544/pcd_8544.h @@ -6,9 +6,9 @@ namespace esphome::pcd8544 { -class PCD8544 : public display::DisplayBuffer, - public spi::SPIDevice { +class PCD8544 final : public display::DisplayBuffer, + public spi::SPIDevice { public: const uint8_t PCD8544_POWERDOWN = 0x04; const uint8_t PCD8544_ENTRYMODE = 0x02; diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 1c6b6bf36d5..659260ba5e5 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -6,7 +6,7 @@ namespace esphome::pcf85063 { -class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -81,12 +81,12 @@ class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf85063_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index 72b600d9ba7..e208774c2c6 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -6,7 +6,7 @@ namespace esphome::pcf8563 { -class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -109,12 +109,12 @@ class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf8563_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index ece472c4bbb..e8f78bae506 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -9,9 +9,9 @@ namespace esphome::pcf8574 { // PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction // so we use uint16_t as bank type to ensure all pins are in one bank and cached together -class PCF8574Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCF8574Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCF8574Component() = default; @@ -49,7 +49,7 @@ class PCF8574Component : public Component, }; /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin : public GPIOPin { +class PCF8574GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index f86b096c821..3c42e4d8d2f 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -41,7 +41,7 @@ enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_32 = 32, }; -class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h index 8edaa6d3e85..0c750ab278b 100644 --- a/esphome/components/pcm5122/pcm5122_gpio.h +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -6,7 +6,7 @@ namespace esphome::pcm5122 { -class PCM5122GPIOPin : public GPIOPin, public Parented { +class PCM5122GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 6225956430a..9909dc2217b 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -6,9 +6,9 @@ #include "esphome/core/hal.h" namespace esphome::pi4ioe5v6408 { -class PI4IOE5V6408Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PI4IOE5V6408Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PI4IOE5V6408Component() = default; @@ -49,7 +49,7 @@ class PI4IOE5V6408Component : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin : public GPIOPin, public Parented { +class PI4IOE5V6408GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 9e3c89ca4d9..7269709ab9b 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -11,7 +11,7 @@ namespace esphome::pid { -class PIDClimate : public climate::Climate, public Component { +class PIDClimate final : public climate::Climate, public Component { public: PIDClimate() = default; void setup() override; @@ -108,7 +108,7 @@ class PIDClimate : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction : public Action { +template class PIDAutotuneAction final : public Action { public: PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} @@ -131,7 +131,7 @@ template class PIDAutotuneAction : public Action { PIDClimate *parent_; }; -template class PIDResetIntegralTermAction : public Action { +template class PIDResetIntegralTermAction final : public Action { public: PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} @@ -141,7 +141,7 @@ template class PIDResetIntegralTermAction : public Action PIDClimate *parent_; }; -template class PIDSetControlParametersAction : public Action { +template class PIDSetControlParametersAction final : public Action { public: PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index d6bdc66a465..b62d5977807 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -18,7 +18,7 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KD, }; -class PIDClimateSensor : public sensor::Sensor, public Component { +class PIDClimateSensor final : public sensor::Sensor, public Component { public: void setup() override; void set_parent(PIDClimate *parent) { parent_ = parent; } diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 4a6e4c29d77..6fc013c2765 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -10,7 +10,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarOutput : public output::FloatOutput { +class PipsolarOutput final : public output::FloatOutput { public: PipsolarOutput() {} void set_parent(Pipsolar *parent) { this->parent_ = parent; } @@ -27,7 +27,7 @@ class PipsolarOutput : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction : public Action { +template class SetOutputAction final : public Action { public: SetOutputAction(PipsolarOutput *output) : output_(output) {} diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 59332080cf4..06c920a6e49 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -56,7 +56,7 @@ struct QFLAGValues { PIPSOLAR_ENTITY_(binary_sensor::BinarySensor, name, polling_command) #define PIPSOLAR_TEXT_SENSOR(name, polling_command) PIPSOLAR_ENTITY_(text_sensor::TextSensor, name, polling_command) -class Pipsolar : public uart::UARTDevice, public PollingComponent { +class Pipsolar final : public uart::UARTDevice, public PollingComponent { // QPIGS values PIPSOLAR_SENSOR(grid_voltage, QPIGS) PIPSOLAR_SENSOR(grid_frequency, QPIGS) diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.h b/esphome/components/pipsolar/switch/pipsolar_switch.h index 20d2640d90e..2b8cda9d590 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.h +++ b/esphome/components/pipsolar/switch/pipsolar_switch.h @@ -6,7 +6,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarSwitch : public switch_::Switch, public Component { +class PipsolarSwitch final : public switch_::Switch, public Component { public: void set_parent(Pipsolar *parent) { this->parent_ = parent; } void set_on_command(const char *command) { this->on_command_ = command; } diff --git a/esphome/components/pm1006/pm1006.h b/esphome/components/pm1006/pm1006.h index 38ab284f476..b32bb2ba8ed 100644 --- a/esphome/components/pm1006/pm1006.h +++ b/esphome/components/pm1006/pm1006.h @@ -7,7 +7,7 @@ namespace esphome::pm1006 { -class PM1006Component : public PollingComponent, public uart::UARTDevice { +class PM1006Component final : public PollingComponent, public uart::UARTDevice { public: PM1006Component() = default; diff --git a/esphome/components/pm2005/pm2005.h b/esphome/components/pm2005/pm2005.h index 9661d082d1b..e4ab9ff3283 100644 --- a/esphome/components/pm2005/pm2005.h +++ b/esphome/components/pm2005/pm2005.h @@ -11,7 +11,7 @@ enum SensorType { PM2105, }; -class PM2005Component : public PollingComponent, public i2c::I2CDevice { +class PM2005Component final : public PollingComponent, public i2c::I2CDevice { public: void set_sensor_type(SensorType sensor_type) { this->sensor_type_ = sensor_type; } From cbcf23426d8d64069d4da7f0e96a0065aa9750d6 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Wed, 24 Jun 2026 10:08:59 +0000 Subject: [PATCH 139/343] [waveshare_io_ch32v003] Waveshare I/O Expander component (#10071) --- CODEOWNERS | 1 + .../waveshare_io_ch32v003/__init__.py | 84 +++++++++ .../waveshare_io_ch32v003/output/__init__.py | 70 ++++++++ .../output/waveshare_io_ch32v003_output.cpp | 19 ++ .../output/waveshare_io_ch32v003_output.h | 22 +++ .../waveshare_io_ch32v003/sensor/__init__.py | 55 ++++++ .../sensor/waveshare_io_ch32v003_sensor.cpp | 27 +++ .../sensor/waveshare_io_ch32v003_sensor.h | 29 +++ .../waveshare_io_ch32v003.cpp | 168 ++++++++++++++++++ .../waveshare_io_ch32v003.h | 65 +++++++ .../waveshare_io_ch32v003/common.yaml | 33 ++++ .../waveshare_io_ch32v003/test.esp32-idf.yaml | 4 + 12 files changed, 577 insertions(+) create mode 100644 esphome/components/waveshare_io_ch32v003/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/output/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/__init__.py create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h create mode 100644 esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp create mode 100644 esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h create mode 100644 tests/components/waveshare_io_ch32v003/common.yaml create mode 100644 tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d425614582c..70ad580e778 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -578,6 +578,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher esphome/components/water_heater/* @dhoeben esphome/components/waveshare_epaper/* @clydebarrow +esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @esphome/core esphome/components/web_server_idf/* @dentra diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py new file mode 100644 index 00000000000..b692b858a3b --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -0,0 +1,84 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@latonita"] + +AUTO_LOAD = ["gpio_expander"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +waveshare_io_ch32v003_ns = cg.esphome_ns.namespace("waveshare_io_ch32v003") + +WaveshareIOCH32V003Component = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Component", cg.Component, i2c.I2CDevice +) +WaveshareIOCH32V003GPIOPin = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003GPIOPin", + cg.GPIOPin, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_WAVESHARE_IO_CH32V003 = "waveshare_io_ch32v003" +CONF_WAVESHARE_IO_CH32V003_ID = "waveshare_io_ch32v003_id" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(WaveshareIOCH32V003Component), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x24)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + +def validate_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( + WaveshareIOCH32V003GPIOPin, + cv.int_range(min=0, max=7), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, + invertible=True, +).extend( + { + cv.Required(CONF_WAVESHARE_IO_CH32V003): cv.use_id( + WaveshareIOCH32V003Component + ), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) +async def waveshare_io_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) + + cg.add(var.set_parent(parent)) + + num = config[CONF_NUMBER] + cg.add(var.set_pin(num)) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py new file mode 100644 index 00000000000..9af9ce7e4b7 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Output = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Output", + output.FloatOutput, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_SAFE_PWM_LEVELS = "safe_pwm_levels" + +DUTY_DEFAULT_MIN = 1 +DUTY_DEFAULT_MAX = 247 + + +def validate_pwm_limits(config): + """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" + + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + if min_val > max_val: + raise cv.Invalid( + f"safe_pwm_levels.min_value ({min_val}) cannot be greater than " + f"safe_pwm_levels.max_value ({max_val})" + ) + return config + + +CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( + min=0, max=255 + ), + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( + min=0, max=255 + ), + } +) + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(WaveshareIOCH32V003Output), + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_SAFE_PWM_LEVELS): CONF_SAFE_PWM_LEVELS_SCHEMA, + } + ), + validate_pwm_limits, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + cg.add(var.set_pwm_safe_range(min_val, max_val)) diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp new file mode 100644 index 00000000000..30458310cef --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp @@ -0,0 +1,19 @@ +#include "waveshare_io_ch32v003_output.h" +#include "esphome/core/log.h" +#include + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.output"; + +void WaveshareIOCH32V003Output::write_state(float state) { + uint8_t pwm_value = static_cast(state * 255.0f); + uint8_t final_pwm_value = std::clamp(pwm_value, this->pwm_min_value_, this->pwm_max_value_); + if (final_pwm_value != pwm_value) { + ESP_LOGVV(TAG, "Clamping PWM value %u to safe range [%u, %u]", pwm_value, this->pwm_min_value_, + this->pwm_max_value_); + } + this->parent_->set_pwm_value(final_pwm_value); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h new file mode 100644 index 00000000000..abe8183692b --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h @@ -0,0 +1,22 @@ +#pragma once + +#include "../waveshare_io_ch32v003.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Output : public output::FloatOutput, public Parented { + public: + void set_pwm_safe_range(uint8_t min_value, uint8_t max_value) { + this->pwm_min_value_ = min_value; + this->pwm_max_value_ = max_value; + } + + protected: + void write_state(float state) override; + + uint8_t pwm_min_value_{1}; + uint8_t pwm_max_value_{247}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py new file mode 100644 index 00000000000..1e060bdfe4d --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_REFERENCE_VOLTAGE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_VOLT, +) + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +AUTO_LOAD = ["voltage_sampler"] +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Sensor = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Sensor", + sensor.Sensor, + cg.PollingComponent, + voltage_sampler.VoltageSampler, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + WaveshareIOCH32V003Sensor, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_REFERENCE_VOLTAGE, default="9.9V"): cv.voltage, + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp new file mode 100644 index 00000000000..82da7451f94 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -0,0 +1,27 @@ +#include "waveshare_io_ch32v003_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.sensor"; + +float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } + +void WaveshareIOCH32V003Sensor::dump_config() { + ESP_LOGCONFIG(TAG, + "WaveshareIOCH32V003Sensor:\n" + " Reference Voltage: %.2fV", + this->reference_voltage_); +} + +float WaveshareIOCH32V003Sensor::sample() { + uint16_t adc_value = this->parent_->get_adc_value(); + // Convert the ADC value to voltage. 10-bit ADC + float voltage = adc_value * this->reference_voltage_ / 1023.0f; + return voltage; +} + +void WaveshareIOCH32V003Sensor::update() { this->publish_state(this->sample()); } + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h new file mode 100644 index 00000000000..01beab5137b --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" + +#include "../waveshare_io_ch32v003.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Sensor : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { + public: + void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } + + void update() override; + void dump_config() override; + float get_setup_priority() const override; + float sample() override; + + protected: + float reference_voltage_{9.9f}; // Default reference voltage for ADC calculations, can be overridden by user config +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp new file mode 100644 index 00000000000..8a58c7e7bb4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -0,0 +1,168 @@ +#include "waveshare_io_ch32v003.h" +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const uint8_t IO_EXTENSION_DIRECTION = 0x02; +static const uint8_t IO_EXTENSION_IO_OUTPUT_ADDR = 0x03; +static const uint8_t IO_EXTENSION_IO_INPUT_ADDR = 0x04; +static const uint8_t IO_EXTENSION_PWM_ADDR = 0x05; +static const uint8_t IO_EXTENSION_ADC_ADDR = 0x06; +static const uint8_t IO_EXTENSION_RTC_INT_ADDR = 0x07; + +static const char *const TAG = "waveshare_io_ch32v003"; + +void WaveshareIOCH32V003Component::setup() { + this->mode_mask_ = 0xFF; // Set all pins to output mode + this->output_mask_ = 0xFF; // Set all pins to high (output mode) + + bool step1 = this->write_gpio_modes_(); + bool step2 = this->write_gpio_outputs_(); + + if (!step1 || !step2) { + ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); + this->mark_failed(); + return; + } + + this->disable_loop(); +} + +void WaveshareIOCH32V003Component::pin_mode(uint8_t pin, gpio::Flags flags) { + // bits: 0 = input, 1 = output + if (flags == gpio::FLAG_INPUT) { + // Clear mode mask bit + this->mode_mask_ &= ~(1 << pin); + this->enable_loop(); + } else if (flags == gpio::FLAG_OUTPUT) { + // Set mode mask bit + this->mode_mask_ |= 1 << pin; + } + this->write_gpio_modes_(); +} + +void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } + +void WaveshareIOCH32V003Component::dump_config() { + ESP_LOGCONFIG(TAG, "WaveshareIO:"); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +uint16_t WaveshareIOCH32V003Component::get_adc_value() { + if (this->is_failed()) + return 0; + + uint8_t data[2]; + if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { + this->status_set_warning(LOG_STR("Failed to read ADC register")); + return 0; + } + uint16_t adc_value = (data[1] << 8) | data[0]; + this->status_clear_warning(); + return adc_value; +} + +uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { + if (this->is_failed()) + return 0; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + return 0; + } + this->status_clear_warning(); + return data; +} + +void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { + if (this->is_failed()) + return; + + // PWM limits are enforced at the output component level to protect hardware + // based on circuit schematic requirements. This follows the pattern from the + // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". + + if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { + this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::write_gpio_modes_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { + this->status_set_warning(LOG_STR("Failed to write mode register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::write_gpio_outputs_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { + if (this->is_failed()) + return false; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->input_mask_ = data; + + this->status_clear_warning(); + return true; +} + +void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { + if (this->is_failed()) + return; + + if (value) { + this->output_mask_ |= (1 << pin); + } else { + this->output_mask_ &= ~(1 << pin); + } + + uint8_t data = this->output_mask_; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } + +void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } +void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } +bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } + +void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { + this->parent_->digital_write(this->pin_, value ^ this->inverted_); +} + +size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h new file mode 100644 index 00000000000..4a31602fa41 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + WaveshareIOCH32V003Component() = default; + + void setup() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + void loop() override; + + uint16_t get_adc_value(); + uint8_t get_rtc_interrupt_status(); + void set_pwm_value(uint8_t value); // 0 - 255 + + protected: + friend class WaveshareIOCH32V003GPIOPin; + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + uint8_t mode_mask_{0x00}; // Mask for the pin mode - 1 means output, 0 means input + uint8_t output_mask_{0x00}; // The mask to write as output state - 1 means HIGH, 0 means LOW + uint8_t input_mask_{0x00}; // The state read in digital_read_hw - 1 means HIGH, 0 means LOW + + bool write_gpio_modes_(); + bool write_gpio_outputs_(); +}; + +/// Helper class to expose a WaveshareIO pin as a GPIO pin. +class WaveshareIOCH32V003GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{}; + bool inverted_{}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/tests/components/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml new file mode 100644 index 00000000000..086b27ab96b --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -0,0 +1,33 @@ +waveshare_io_ch32v003: + - id: wave_io + address: 0x24 + +binary_sensor: + - platform: gpio + id: wave_io_binary_sensor + pin: + waveshare_io_ch32v003: wave_io + number: 3 + mode: INPUT + inverted: false + +output: + - platform: gpio + id: wave_io_output + pin: + waveshare_io_ch32v003: wave_io + number: 0 + mode: OUTPUT + inverted: false + + - platform: waveshare_io_ch32v003 + id: wave_io_pwm_output + inverted: true + zero_means_zero: true + safe_pwm_levels: + min_value: 0 + max_value: 247 + +sensor: + - platform: waveshare_io_ch32v003 + id: wave_io_adc diff --git a/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 18f29f8d2b78f2e6fbceb1a2bab34c95dce73032 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:05:23 +1000 Subject: [PATCH 140/343] [mipi_spi] Suppress sequence errors when page selection used (#17176) --- esphome/components/mipi/__init__.py | 1 + esphome/components/mipi_spi/display.py | 20 ++-- esphome/components/mipi_spi/mipi_spi.h | 8 -- .../mipi_spi/test_page_selection.py | 113 ++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/mipi_spi/test_page_selection.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index caa33cd834e..2244a316b7c 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -120,6 +120,7 @@ CSCON = 0xF0 PWCTR6 = 0xF6 ADJCTL3 = 0xF7 PAGESEL = 0xFE +PAGESEL1 = 0xFF MADCTL_MY = 0x80 # Bit 7 Bottom to top MADCTL_MX = 0x40 # Bit 6 Right to left diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index d613d0a1ab0..0231d125297 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -17,6 +17,8 @@ from esphome.components.mipi import ( MADCTL, MODE_BGR, MODE_RGB, + PAGESEL, + PAGESEL1, PIXFMT, DriverChip, dimension_schema, @@ -276,14 +278,16 @@ def customise_schema(config): # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): commands = [x[0] for x in init_sequence] - if MADCTL in commands and CONF_TRANSFORM in config: - raise cv.Invalid( - f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" - ) - if PIXFMT in commands: - raise cv.Invalid( - f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" - ) + # If there is page swapping, we can't rely on recognising common commands + if PAGESEL not in commands and PAGESEL1 not in commands: + if MADCTL in commands and CONF_TRANSFORM in config: + raise cv.Invalid( + f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" + ) + if PIXFMT in commands: + raise cv.Invalid( + f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" + ) if bus_mode == TYPE_QUAD and CONF_DC_PIN in config: raise cv.Invalid("DC pin is not supported in quad mode") diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index a594e482098..d9627899e04 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -176,7 +176,6 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - auto arg_byte = vec[index]; switch (cmd) { case SLEEP_OUT: { // are we ready, boots? @@ -187,13 +186,6 @@ class MipiSpi : public display::Display, } } break; - case INVERT_ON: - this->invert_colors_ = true; - break; - case BRIGHTNESS: - this->brightness_ = arg_byte; - break; - default: break; } diff --git a/tests/component_tests/mipi_spi/test_page_selection.py b/tests/component_tests/mipi_spi/test_page_selection.py new file mode 100644 index 00000000000..4b1ec222717 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_page_selection.py @@ -0,0 +1,113 @@ +"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT. + +Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present) +and the error behaviour when neither page-selection command is present. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT +from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: dict[str, Any]) -> dict[str, Any]: + """Run schema + final validation and return the validated config.""" + cfg = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(cfg) + return cfg + + +def test_madctl_error_suppressed_when_pagesel_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL is present in init_sequence, MADCTL presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_pixfmt_error_suppressed_when_pagesel1_present( + set_core_config: SetCoreConfigCallable, +) -> None: + """If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]], + } + + # Should not raise + validated = validated_config(cfg) + assert validated is not None + + +def test_madctl_raises_without_pagesel( + set_core_config: SetCoreConfigCallable, +) -> None: + """MADCTL in the init_sequence should raise when a transform is configured and + no PAGESEL/PAGESEL1 is present. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False}, + "init_sequence": [[MADCTL, 0x01]], + } + + with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"): + CONFIG_SCHEMA(cfg) + + +def test_pixfmt_raises_without_pagesel1( + set_core_config: SetCoreConfigCallable, +) -> None: + """PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + cfg: dict[str, Any] = { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[PIXFMT, 0x01]], + } + + with pytest.raises( + cv.Invalid, match=r"PIXFMT .* should not be in the init sequence" + ): + CONFIG_SCHEMA(cfg) From e8acd24fd9f2d8f111195a6d62996065f1cb2b49 Mon Sep 17 00:00:00 2001 From: Geoffrey Frogeye Date: Wed, 24 Jun 2026 15:29:57 +0200 Subject: [PATCH 141/343] [opentherm] Support power scaling disabled (#17183) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/opentherm/output/opentherm_output.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 2735c85d069..4092358d758 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output"; void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); - this->state = state < 0.003 && this->zero_means_zero_ - ? 0.0 - : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING + bool zero_means_zero = this->zero_means_zero_; +#else + bool zero_means_zero = false; +#endif + this->state = + state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } From f471329d606017e0f1df58a3d153486c45de22d0 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:15:00 +0200 Subject: [PATCH 142/343] Bump bundled esphome-device-builder to 1.0.16 (#17182) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1cd33722550..c4a49b778ff 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.15 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 RUN \ platformio settings set enable_telemetry No \ From 72b663fc40b6e7fe647ccf111c7e2ce0b48a9ce9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:22 +0200 Subject: [PATCH 143/343] Bump bundled esphome-device-builder to 1.0.17 (#17199) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c4a49b778ff..c02aba093cd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.16 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 RUN \ platformio settings set enable_telemetry No \ From b68847444440bd6986d2d5ac48a7156bfdfc63a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:37 +0200 Subject: [PATCH 144/343] Bump ruff from 0.15.18 to 0.15.19 (#17195) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4e498abc21e..6e53a4c14fa 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.18 # also change in .pre-commit-config.yaml when updating +ruff==0.15.19 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From fa34c679500f4b076e12c9908204bfb34a8d723d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:04:52 +0200 Subject: [PATCH 145/343] Bump CodSpeedHQ/action from 4.17.6 to 4.18.1 (#17198) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a7233b3a87..f8c1410cec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 + uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 with: run: | . venv/bin/activate From 155439be74710c8227ccf8b9442415b93d7e11bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:05:13 +0200 Subject: [PATCH 146/343] Bump actions/setup-python from 6.2.0 to 6.3.0 (#17197) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2155b67b25d..17234e811aa 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up uv diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 8301f8e9e37..9678831b501 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -63,7 +63,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up Docker Buildx @@ -147,7 +147,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c1410cec0..eaa04ceca6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -162,7 +162,7 @@ jobs: ref: main path: device-builder - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Set up uv @@ -360,7 +360,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3056d9e7d6e..2b23b561bd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 05036f3500f..0501d6d364f 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -37,7 +37,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.14" From aff5e248edea456706daad0f713dda9a185d6608 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:05:30 +0200 Subject: [PATCH 147/343] Bump actions/setup-python from 6.2.0 to 6.3.0 in /.github/actions/restore-python (#17194) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 96a3be53c60..6290e25d7c7 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From e96717f6cd12e6371bf83fb8e143f494d44973f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:53 -0400 Subject: [PATCH 148/343] [waveshare_io_ch32v003] Pin i2c_id in test to avoid grouping conflict (#17191) --- tests/components/waveshare_io_ch32v003/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml index 086b27ab96b..c8805583c72 100644 --- a/tests/components/waveshare_io_ch32v003/common.yaml +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -1,5 +1,6 @@ waveshare_io_ch32v003: - id: wave_io + i2c_id: i2c_bus address: 0x24 binary_sensor: From 538f554bdb0d384d7627cbdcc2e7772845004a88 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:38:55 -0400 Subject: [PATCH 149/343] [psram] Support ESP32-S31/H4 (#17192) --- esphome/components/psram/__init__.py | 12 ++++++++---- tests/component_tests/psram/test_psram.py | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 296ea6c08c7..84683e9a250 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -10,9 +10,11 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C5, VARIANT_ESP32C61, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, idf_version, @@ -57,8 +59,10 @@ SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32C61: (TYPE_QUAD,), + VARIANT_ESP32H4: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), + VARIANT_ESP32S31: (TYPE_OCTAL,), VARIANT_ESP32P4: (TYPE_HEX,), } @@ -67,8 +71,10 @@ SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32C61: (40, 80), + VARIANT_ESP32H4: (32, 64), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), + VARIANT_ESP32S31: (40, 100, 200, 250), VARIANT_ESP32P4: (20, 100, 200), } @@ -145,10 +151,8 @@ def validate_psram_mode(config): raise cv.Invalid("ECC is only available in octal mode.") if config[CONF_MODE] == TYPE_OCTAL: variant = get_esp32_variant() - if variant != VARIANT_ESP32S3: - raise cv.Invalid( - f"Octal PSRAM is only supported on ESP32-S3, not {variant}" - ) + if TYPE_OCTAL not in SPIRAM_MODES.get(variant, ()): + raise cv.Invalid(f"Octal PSRAM is not supported on {variant}") return config diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index ea4adc69a99..4a1ed2c72af 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -12,9 +12,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, PlatformFramework @@ -25,21 +28,26 @@ UNSUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H21, ] SUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C5, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] SUPPORTED_PSRAM_MODES = { VARIANT_ESP32: ["quad"], VARIANT_ESP32C5: ["quad"], + VARIANT_ESP32H4: ["quad"], VARIANT_ESP32P4: ["hex"], VARIANT_ESP32S2: ["quad"], VARIANT_ESP32S3: ["quad", "octal"], + VARIANT_ESP32S31: ["octal"], } @@ -187,7 +195,7 @@ def _setup_psram_final_validation_test( {"mode": "octal"}, {"variant": "ESP32"}, True, - r"Octal PSRAM is only supported on ESP32-S3", + r"Octal PSRAM is not supported on ESP32", id="octal_mode_only_esp32s3", ), pytest.param( From 91e515ca7cccb749645551038990911c4cbaf717 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:28 -0400 Subject: [PATCH 150/343] [esp32] Accept '#' as ESP-IDF source ref separator (#17193) --- esphome/espidf/framework.py | 9 ++++++--- tests/unit_tests/test_espidf_framework.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index f0715ce3b22..c994ce2410c 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -337,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info])) _GITHUB_SHORTHAND_RE = re.compile( - r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) _GITHUB_HTTPS_RE = re.compile( - r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$" + r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$" ) def _parse_git_source(source_url: str) -> tuple[str, str | None] | None: """Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or - ``https://github.com/owner/repo.git[@ref]``, else ``None``.""" + ``https://github.com/owner/repo.git[@ref]``, else ``None``. + + The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO + convention used for ``platform_version`` URLs.""" if m := _GITHUB_SHORTHAND_RE.match(source_url): owner, repo, ref = m.group(1), m.group(2), m.group(3) # Tolerate a trailing ".git" on the shorthand repo so the diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index b5fa0e26980..fe888ac8b92 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -65,6 +65,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl "https://github.com/espressif/esp-idf.git@v6.0.1", ("https://github.com/espressif/esp-idf.git", "v6.0.1"), ), + # '#' ref separator (PlatformIO/git-web convention) works on both forms + ( + "https://github.com/espressif/esp-idf.git#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf#release/v6.1", + ("https://github.com/espressif/esp-idf.git", "release/v6.1"), + ), + ( + "github://espressif/esp-idf.git#master", + ("https://github.com/espressif/esp-idf.git", "master"), + ), # Tolerate a trailing ".git" on the shorthand so the user doesn't # silently end up with a doubled "...esp-idf.git.git" URL. ( From 23aff5202b1a73e63cecd0c13f394e1266fff1b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:42:41 -0400 Subject: [PATCH 151/343] [wifi][openthread] Wire ESP32-S31/H4/H21 radio support (#17186) --- esphome/components/openthread/__init__.py | 12 +++++++++++- esphome/components/wifi/__init__.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 215f9212293..2dc8a783dfd 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -3,6 +3,9 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, @@ -187,7 +190,14 @@ def _validate_platform(config): if CORE.using_zephyr: return config return only_on_variant( - supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + supported=[ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, + ] )(config) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1cfd2b9821a..512fd63e125 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -76,14 +76,20 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] -NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] +NO_WIFI_VARIANTS = [ + const.VARIANT_ESP32H2, + const.VARIANT_ESP32H4, + const.VARIANT_ESP32H21, + const.VARIANT_ESP32P4, +] def variant_has_wifi(variant: str) -> bool: """Return True if *variant* has a native WiFi PHY. - Variants without a native PHY (ESP32-H2, ESP32-P4) need the - ``esp32_hosted`` co-processor to use ``wifi:``. + Variants without a native PHY (see ``NO_WIFI_VARIANTS`` — currently + ESP32-H2, ESP32-H4, ESP32-H21, ESP32-P4) need the ``esp32_hosted`` + co-processor to use ``wifi:``. Case-insensitive on *variant* so external callers can pass either the upstream uppercase form (e.g. ``"ESP32H2"`` from From abbcfd213fa66093a680fc955dfc2d4050e1ae28 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:51:03 -0400 Subject: [PATCH 152/343] [tinyusb][usb_cdc_acm][usb_host][usb_uart] Support ESP32-S31/H4 (#17190) --- esphome/components/tinyusb/__init__.py | 15 ++++++++++++--- esphome/components/tinyusb/tinyusb_component.cpp | 6 ++++-- esphome/components/tinyusb/tinyusb_component.h | 6 ++++-- esphome/components/usb_cdc_acm/__init__.py | 10 +++++++++- esphome/components/usb_cdc_acm/usb_cdc_acm.cpp | 3 ++- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 3 ++- .../components/usb_cdc_acm/usb_cdc_acm_esp32.cpp | 3 ++- esphome/components/usb_host/__init__.py | 14 ++++++++++++-- esphome/components/usb_host/usb_host.h | 6 ++++-- esphome/components/usb_host/usb_host_client.cpp | 6 ++++-- .../components/usb_host/usb_host_component.cpp | 6 ++++-- esphome/components/usb_uart/ch34x.cpp | 6 ++++-- esphome/components/usb_uart/cp210x.cpp | 6 ++++-- esphome/components/usb_uart/ft23xx.cpp | 6 ++++-- esphome/components/usb_uart/pl2303.cpp | 6 ++++-- esphome/components/usb_uart/usb_uart.cpp | 6 ++++-- esphome/components/usb_uart/usb_uart.h | 6 ++++-- esphome/idf_component.yml | 8 ++++---- 18 files changed, 87 insertions(+), 35 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 0e02ff87247..9e1ad3afc41 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -2,9 +2,11 @@ from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, ) @@ -44,7 +46,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) @@ -64,7 +72,8 @@ def _final_validate(config): "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " "because both share the USB OTG peripheral. Set " "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " - "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + "USB_SERIAL_JTAG on variants that support it " + "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) return config @@ -85,7 +94,7 @@ async def to_code(config): if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - add_idf_component(name="espressif/esp_tinyusb", ref="2.1.1") + add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 567a84f8c36..b7489595714 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "tinyusb_component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -61,4 +62,5 @@ void TinyUSB::dump_config() { } } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 56c33a708f9..7ec3da118c1 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "tinyusb.h" @@ -69,4 +70,5 @@ class TinyUSB : public Component { }; } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index bfe177a4da3..8cd078ab496 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,9 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, ) import esphome.config_validation as cv @@ -48,7 +50,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 40f7f2e28bb..454c049da3c 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 89405ab8939..10692fd436e 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 592207efa8b..859d6cbaeab 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 8e591bd80c7..70425c27ca1 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,8 +1,10 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, idf_version, @@ -70,7 +72,15 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()), } ), - only_on_variant(supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3]), + only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ] + ), _set_max_packet_size, ) @@ -84,7 +94,7 @@ async def register_usb_client(config): async def to_code(config: ConfigType) -> None: # IDF 6.0 moved USB host to an external component if idf_version() >= cv.Version(6, 0, 0): - add_idf_component(name="espressif/usb", ref="1.3.0") + add_idf_component(name="espressif/usb", ref="1.4.1") add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024) if config.get(CONF_ENABLE_HUBS): add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index a9f07a5422d..57640e86913 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,8 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" #include @@ -195,4 +196,5 @@ class USBHost : public Component { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 45e2be17c77..7bc2b0a16b7 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -581,4 +582,5 @@ void USBClient::release_trq(TransferRequest *trq) { } } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 8ce0a70dc93..102311348aa 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include #include "esphome/core/log.h" @@ -29,4 +30,5 @@ void USBHost::loop() { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index c5f904ead16..e84384be5d4 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -170,4 +171,5 @@ std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_ } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 67fd03a813f..c4edaed0386 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -122,4 +123,5 @@ void USBUartTypeCP210X::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index c2c8993805b..3b0e05ba537 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -455,4 +456,5 @@ void USBUartTypeFT23XX::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index a50f1cf2d4a..3685debef4c 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -295,4 +296,5 @@ void USBUartTypePL2303::enable_channels() { } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3fdf35a4720..b8749b6a762 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -541,4 +542,5 @@ void USBUartTypeCdcAcm::start_channels_() { } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index d0dccf42b96..c4fb77bdb2d 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" @@ -286,4 +287,5 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f8f3df57cd0..81c16f2e38b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -84,9 +84,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: - version: "2.1.1" + version: "2.2.1" rules: - - if: "target in [esp32s2, esp32s3, esp32p4]" + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esphome/esp-hub75: version: 0.3.5 rules: @@ -96,9 +96,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/usb: - version: "1.3.0" + version: "1.4.1" rules: - - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" + - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: From 1dfafce06a55d87e5d83ab326f0e48e6f3aa4b09 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:05:29 -0400 Subject: [PATCH 153/343] [i2c][spi] Wire ESP32-S31/H4/H21 bus capabilities (#17188) --- esphome/components/esp32/gpio_esp32_s31.py | 17 +++++++++++++++-- esphome/components/i2c/__init__.py | 8 ++++++++ esphome/components/spi/__init__.py | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 6a19e3fee4b..d49240723b7 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -2,13 +2,16 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin # Per the ESP32-S31 datasheet (page 96): # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf _ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} -_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} +# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source. +_ESP32S31_STRAPPING_PINS: set[int] = {37, 60, 61} +# LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. +_ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} _LOGGER = logging.getLogger(__name__) @@ -36,3 +39,13 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s31_validate_lp_i2c(value): + lp_sda_pin = _ESP32S31_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32S31_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-S31" + ) + return value diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index d9dd6d5ee2d..eec2211a960 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -13,14 +13,18 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, get_esp32_variant, ) from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_s31 import esp32_s31_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -72,14 +76,18 @@ ESP32_I2C_CAPABILITIES = { VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H4: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H21: {"NUM": 2, "HP": 2}, VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S31: {"NUM": 3, "HP": 2, "LP": 1}, } VALIDATE_LP_I2C = { VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, + VARIANT_ESP32S31: esp32_s31_validate_lp_i2c, } LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 33ccfbb5ee8..d1961cec59c 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -174,6 +175,7 @@ def get_hw_interface_list(): VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] From 92554f4e67176e950ff98e5af16b95bc2ff920fe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:06 -0400 Subject: [PATCH 154/343] [network] Set IPv4 type tag on all lwIP platforms, not just esp32 (#17200) --- esphome/components/network/ip_address.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 55bb2a1c893..d8a127f4a0a 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -119,7 +119,7 @@ struct IPAddress { IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); } IPAddress(ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t)); -#if USE_ESP32 && LWIP_IPV6 +#if LWIP_IPV6 ip_addr_.type = IPADDR_TYPE_V4; #endif } From 8c9f4fba8fdeb5a942f764e14f0e0194ec8a42fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:28:15 -0400 Subject: [PATCH 155/343] [wifi] Report STA IP, not SoftAP IP, in wifi_info on ESP8266 (#17185) --- esphome/components/wifi/wifi_component_esp8266.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 717d542fbef..84b864c0c5d 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { return {}; network::IPAddresses addresses; uint8_t index = 0; + // addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out + // the AP address so the STA address is reported as the device IP (see issue #17181). + struct ip_info ap_ip {}; + wifi_get_ip_info(SOFTAP_IF, &ap_ip); + network::IPAddress ap_address(&ap_ip.ip); + bool filter_ap = ap_address.is_set(); for (auto &addr : addrList) { + network::IPAddress ip(addr.ipFromNetifNum()); + if (filter_ap && ip == ap_address) + continue; assert(index < addresses.size()); - addresses[index++] = addr.ipFromNetifNum(); + addresses[index++] = ip; } return addresses; } From 23933c1b58065bd600f9159f2a8f29728b19b976 Mon Sep 17 00:00:00 2001 From: Julian Lunz <117189+jlunz@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:21:41 +0200 Subject: [PATCH 156/343] [adc] Only call cyw43_thread_enter/exit for VSYS when WiFi is active on RP2040 (#17203) --- esphome/components/adc/adc_sensor_rp2040.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8d41edb8145..894c346588c 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -66,15 +66,18 @@ float ADCSensor::sample() { } uint8_t pin = this->pin_->get_pin(); -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { // Measuring VSYS on Raspberry Pico W needs to be wrapped with // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and - // VSYS ADC both share GPIO29 + // VSYS ADC both share GPIO29. + // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined + // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43 + // driver is never initialized; calling cyw43_thread_enter() there hard-faults. cyw43_thread_enter(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) adc_gpio_init(pin); adc_select_input(pin - 26); @@ -84,11 +87,11 @@ float ADCSensor::sample() { aggr.add_sample(raw); } -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { cyw43_thread_exit(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (this->output_raw_) { return aggr.aggregate(); From d8eee03556dcd6e61e014f6ae63ca6ae537d8877 Mon Sep 17 00:00:00 2001 From: Fae Date: Thu, 25 Jun 2026 11:20:15 +0100 Subject: [PATCH 157/343] [host] Fix handling of directory for preferences (#11160) --- esphome/components/host/preference_backend.h | 4 +- esphome/components/host/preferences.cpp | 52 ++++-- esphome/components/host/preferences.h | 8 +- tests/components/host/preferences_test.cpp | 174 +++++++++++++++++++ 4 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 tests/components/host/preferences_test.cpp diff --git a/esphome/components/host/preference_backend.h b/esphome/components/host/preference_backend.h index 68537cad28f..ab1443ed49f 100644 --- a/esphome/components/host/preference_backend.h +++ b/esphome/components/host/preference_backend.h @@ -10,8 +10,8 @@ class HostPreferenceBackend final { public: explicit HostPreferenceBackend(uint32_t key) : key_(key) {} - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); + bool save(const uint8_t *data, size_t len) const; + bool load(uint8_t *data, size_t len) const; protected: uint32_t key_{}; diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index c0be2700622..497b9d11e58 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -14,21 +14,31 @@ static const char *const TAG = "preferences"; void HostPreferences::setup_() { if (this->setup_complete_) return; - const char *home = getenv("HOME"); - if (home == nullptr) { - ESP_LOGE(TAG, "HOME environment variable is not set"); - abort(); + const char *prefdir = getenv("ESPHOME_PREFDIR"); + std::string pref_path; + if (prefdir != nullptr) { + pref_path = prefdir; + } else { + const char *home = getenv("HOME"); + if (home == nullptr) { + ESP_LOGE(TAG, "ESPHOME_PREFDIR and HOME environment variables not set, unable to save preferences"); + return; + } + pref_path = std::string(home) + "/.esphome/prefs"; } - this->filename_.append(home); - this->filename_.append("/.esphome"); - this->filename_.append("/prefs"); - fs::create_directories(this->filename_); + std::error_code ec; + fs::create_directories(pref_path, ec); + if (ec) { + ESP_LOGE(TAG, "Failed to create preferences directory: %s (%s)", pref_path.c_str(), ec.message().c_str()); + return; + } + this->filename_ = pref_path; this->filename_.append("/"); this->filename_.append(App.get_name()); this->filename_.append(".prefs"); FILE *fp = fopen(this->filename_.c_str(), "rb"); if (fp != nullptr) { - while (!feof((fp))) { + while (!feof(fp)) { uint32_t key; uint8_t len; if (fread(&key, sizeof(key), 1, fp) != 1) @@ -39,7 +49,7 @@ void HostPreferences::setup_() { if (fread(data, sizeof(uint8_t), len, fp) != len) break; std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; } fclose(fp); } @@ -48,29 +58,33 @@ void HostPreferences::setup_() { bool HostPreferences::sync() { this->setup_(); + if (this->filename_.empty()) { + ESP_LOGE(TAG, "Preferences filename not set, unable to save preferences"); + return false; + } FILE *fp = fopen(this->filename_.c_str(), "wb"); if (fp == nullptr) { ESP_LOGE(TAG, "Failed to open preferences file for writing: %s", this->filename_.c_str()); return false; } - for (auto it = this->data.begin(); it != this->data.end(); ++it) { - fwrite(&it->first, sizeof(uint32_t), 1, fp); - uint8_t len = it->second.size(); + for (auto &it : this->data_) { + fwrite(&it.first, sizeof(uint32_t), 1, fp); + uint8_t len = it.second.size(); fwrite(&len, sizeof(len), 1, fp); - fwrite(it->second.data(), sizeof(uint8_t), it->second.size(), fp); + fwrite(it.second.data(), sizeof(uint8_t), it.second.size(), fp); } fclose(fp); return true; } bool HostPreferences::reset() { - host_preferences->data.clear(); + host_preferences->data_.clear(); return true; } ESPPreferenceObject HostPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { - auto backend = new HostPreferenceBackend(type); + auto *backend = new HostPreferenceBackend(type); return ESPPreferenceObject(backend); }; @@ -83,11 +97,13 @@ void setup_preferences() { global_preferences = &s_preferences; } -bool HostPreferenceBackend::save(const uint8_t *data, size_t len) { +bool HostPreferenceBackend::save(const uint8_t *data, size_t len) const { return host_preferences->save(this->key_, data, len); } -bool HostPreferenceBackend::load(uint8_t *data, size_t len) { return host_preferences->load(this->key_, data, len); } +bool HostPreferenceBackend::load(uint8_t *data, size_t len) const { + return host_preferences->load(this->key_, data, len); +} HostPreferences *host_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 25858799ff1..5f723e06758 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -23,7 +23,7 @@ class HostPreferences final : public PreferencesMixin { return false; this->setup_(); std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; return true; } @@ -31,8 +31,8 @@ class HostPreferences final : public PreferencesMixin { if (len > 255) return false; this->setup_(); - auto it = this->data.find(key); - if (it == this->data.end()) + auto it = this->data_.find(key); + if (it == this->data_.end()) return false; const auto &vec = it->second; if (vec.size() != len) @@ -45,7 +45,7 @@ class HostPreferences final : public PreferencesMixin { void setup_(); bool setup_complete_{}; std::string filename_{}; - std::map> data{}; + std::map> data_{}; }; void setup_preferences(); diff --git a/tests/components/host/preferences_test.cpp b/tests/components/host/preferences_test.cpp new file mode 100644 index 00000000000..8e79db04f50 --- /dev/null +++ b/tests/components/host/preferences_test.cpp @@ -0,0 +1,174 @@ +#ifdef USE_HOST +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// RAII helper to save and restore an environment variable. +class ScopedEnvVar { + public: + explicit ScopedEnvVar(const char *name) : name_(name) { + const char *val = getenv(name); + if (val != nullptr) { + saved_value_ = val; + was_set_ = true; + } + } + ~ScopedEnvVar() { + if (this->was_set_) { + setenv(this->name_.c_str(), this->saved_value_.c_str(), 1); + } else { + unsetenv(this->name_.c_str()); + } + } + ScopedEnvVar(const ScopedEnvVar &) = delete; + ScopedEnvVar &operator=(const ScopedEnvVar &) = delete; + + private: + std::string name_; + std::string saved_value_; + bool was_set_{false}; +}; + +class HostPreferencesTest : public ::testing::Test { + protected: + void SetUp() override { + // Create a unique temp directory for this test + this->temp_dir_ = fs::temp_directory_path() / "esphome_prefs_test"; + fs::create_directories(this->temp_dir_); + + // Set up App name — string literal has static storage so StringRef is safe + App.pre_setup("test_prefs", 10, "", 0); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(this->temp_dir_, ec); + } + + fs::path temp_dir_; +}; + +TEST_F(HostPreferencesTest, BothVarsUnset_SyncReturnsFalse) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, BothVarsUnset_SaveSucceedsInMemory) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + // save() stores in memory even without a valid file path + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + + // But sync to disk should fail + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, PrefDirSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + unsetenv("HOME"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in ESPHOME_PREFDIR + auto expected_file = prefdir / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, HomeSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto home = this->temp_dir_ / "home"; + setenv("HOME", home.c_str(), 1); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in HOME/.esphome/prefs + auto expected_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, PrefDirTakesPrecedenceOverHome) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + auto home = this->temp_dir_ / "home"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + setenv("HOME", home.c_str(), 1); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // File should be in ESPHOME_PREFDIR, not HOME + auto prefdir_file = prefdir / "test_prefs.prefs"; + auto home_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(prefdir_file)); + EXPECT_FALSE(fs::exists(home_file)); +} + +TEST_F(HostPreferencesTest, SaveAndLoadRoundTrip) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "roundtrip"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + // Save data with one instance + { + HostPreferences prefs; + uint32_t value = 0xDEADBEEF; + EXPECT_TRUE(prefs.save(0xABCD, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + } + + // Load with a fresh instance (reads from file) + { + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_TRUE(prefs.load(0xABCD, reinterpret_cast(&loaded), sizeof(loaded))); + EXPECT_EQ(loaded, 0xDEADBEEFu); + } +} + +TEST_F(HostPreferencesTest, LoadNonExistentKeyReturnsFalse) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "nokey"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_FALSE(prefs.load(0x9999, reinterpret_cast(&loaded), sizeof(loaded))); +} + +} // namespace esphome::host::testing + +#endif From 8c68e9556872b85010e2622033609f376cd56225 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:33:28 +1200 Subject: [PATCH 158/343] [config_validation] Add tests for 100% validator coverage (#17204) --- tests/unit_tests/test_config_validation.py | 1908 +++++++++++++++++--- 1 file changed, 1694 insertions(+), 214 deletions(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 9b9f003b0d4..2715f9c644c 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +from pathlib import Path import string from hypothesis import example, given, settings @@ -5,7 +6,7 @@ from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation +from esphome import config_validation as cv from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -17,6 +18,22 @@ from esphome.components.esp32 import ( ) from esphome.config_validation import Invalid from esphome.const import ( + CONF_DAY, + CONF_HOUR, + CONF_ID, + CONF_INTERNAL, + CONF_MINUTE, + CONF_MONTH, + CONF_NAME, + CONF_REF, + CONF_SECOND, + CONF_TYPE, + CONF_VALUE, + CONF_YEAR, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -25,19 +42,35 @@ from esphome.const import ( PLATFORM_RP2040, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, + TYPE_GIT, + TYPE_LOCAL, + Framework, ) -from esphome.core import CORE, HexInt, Lambda -from esphome.yaml_util import SensitiveStr +from esphome.core import ( + CORE, + ID, + HexInt, + Lambda, + MACAddress, + TimePeriod, + TimePeriodMicroseconds, + TimePeriodMinutes, + TimePeriodNanoseconds, + TimePeriodSeconds, +) +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.util import Registry +from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base def test_check_not_templatable__invalid(): with pytest.raises(Invalid, match="This option is not templatable!"): - config_validation.check_not_templatable(Lambda("")) + cv.check_not_templatable(Lambda("")) @pytest.mark.parametrize("value", ("foo", 1, "D12", False)) def test_alphanumeric__valid(value): - actual = config_validation.alphanumeric(value) + actual = cv.alphanumeric(value) assert actual == str(value) @@ -45,12 +78,12 @@ def test_alphanumeric__valid(value): @pytest.mark.parametrize("value", ("£23", "Foo!")) def test_alphanumeric__invalid(value): with pytest.raises(Invalid): - config_validation.alphanumeric(value) + cv.alphanumeric(value) @given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) def test_valid_name__valid(value): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value @@ -58,29 +91,29 @@ def test_valid_name__valid(value): @pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) def test_valid_name__invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) def test_valid_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) @pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) def test_valid_name__substitution_like_invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) def test_validate_id_name__valid(value): - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value @@ -88,23 +121,23 @@ def test_validate_id_name__valid(value): @pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) def test_validate_id_name__invalid(value): with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) def test_validate_id_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @given(one_of(integers(), text())) def test_string__valid(value): - actual = config_validation.string(value) + actual = cv.string(value) assert actual == str(value) @@ -112,12 +145,12 @@ def test_string__valid(value): @pytest.mark.parametrize("value", ({}, [], True, False, None)) def test_string__invalid(value): with pytest.raises(Invalid): - config_validation.string(value) + cv.string(value) @given(text()) def test_strict_string__valid(value): - actual = config_validation.string_strict(value) + actual = cv.string_strict(value) assert actual == value @@ -125,29 +158,29 @@ def test_strict_string__valid(value): @pytest.mark.parametrize("value", (None, 123)) def test_string_string__invalid(value): with pytest.raises(Invalid, match="Must be string, got"): - config_validation.string_strict(value) + cv.string_strict(value) def test_sensitive__default_delegates_to_string() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator.inner is config_validation.string + assert isinstance(validator, cv.SensitiveValidator) + assert validator.inner is cv.string assert validator("hunter2") == "hunter2" assert validator(42) == "42" def test_sensitive__custom_inner_delegates_validation() -> None: - validator = config_validation.sensitive(config_validation.string_strict) + validator = cv.sensitive(cv.string_strict) - assert validator.inner is config_validation.string_strict + assert validator.inner is cv.string_strict assert validator("abc") == "abc" with pytest.raises(Invalid, match="Must be string, got"): validator(123) def test_sensitive__wraps_string_result_in_sensitive_str() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() result = validator("hunter2") assert isinstance(result, SensitiveStr) @@ -164,7 +197,7 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: def inner(_value): return pre_tagged - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) result = validator("anything") assert result is pre_tagged @@ -178,22 +211,22 @@ def test_sensitive__non_string_result_passes_through() -> None: def inner(_value): return sentinel - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) assert validator("anything") is sentinel def test_sensitive__is_detectable_via_isinstance() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) + assert isinstance(validator, cv.SensitiveValidator) def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for # frontend masking and validating a value directly tags the result. - assert isinstance(config_validation.bind_key, config_validation.SensitiveValidator) + assert isinstance(cv.bind_key, cv.SensitiveValidator) - result = config_validation.bind_key("0123456789ABCDEF0123456789ABCDEF") + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF") assert isinstance(result, SensitiveStr) assert result == "0123456789ABCDEF0123456789ABCDEF" @@ -202,9 +235,7 @@ def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: def test_bind_key__bare_usage_in_schema() -> None: # Voluptuous calls the bare validator with the config value; the result must # come through tagged sensitive. - schema = config_validation.Schema( - {config_validation.Required("key"): config_validation.bind_key} - ) + schema = cv.Schema({cv.Required("key"): cv.bind_key}) out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) assert isinstance(out["key"], SensitiveStr) @@ -213,10 +244,10 @@ def test_bind_key__bare_usage_in_schema() -> None: def test_bind_key__factory_returns_sensitive_validator() -> None: # Called with a name (cv.bind_key(name=...)) it returns a new sensitive # validator rather than validating. - validator = config_validation.bind_key(name="Decryption key") + validator = cv.bind_key(name="Decryption key") - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator is not config_validation.bind_key + assert isinstance(validator, cv.SensitiveValidator) + assert validator is not cv.bind_key assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) @@ -229,7 +260,7 @@ def test_bind_key__factory_returns_sensitive_validator() -> None: ) def test_bind_key__custom_name_in_error(value: str, error: str) -> None: # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. - validator = config_validation.bind_key(name="Decryption key") + validator = cv.bind_key(name="Decryption key") with pytest.raises(Invalid, match=error): validator(value) @@ -238,25 +269,23 @@ def test_bind_key__rejects_non_hex_pair_length() -> None: # Odd-length input yields a trailing single-char part, hitting the # "format XX" branch rather than the hex-value branch. with pytest.raises(Invalid, match="Bind key must be format XX"): - config_validation.bind_key("0123456789ABCDEF0123456789ABCDE") + cv.bind_key("0123456789ABCDEF0123456789ABCDE") def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: # Passing both a value and a name validates immediately using the custom # name for error wording, and still tags the result sensitive. - result = config_validation.bind_key( - "0123456789ABCDEF0123456789ABCDEF", name="Decryption key" - ) + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF", name="Decryption key") assert isinstance(result, SensitiveStr) with pytest.raises(Invalid, match="Decryption key must consist of"): - config_validation.bind_key("00", name="Decryption key") + cv.bind_key("00", name="Decryption key") def test_bind_key__factory_without_name_keeps_existing_name() -> None: # Re-invoking a named validator without a name preserves its name rather # than resetting to the default. - named = config_validation.bind_key(name="Decryption key") + named = cv.bind_key(name="Decryption key") rederived = named() with pytest.raises(Invalid, match="Decryption key must consist of"): @@ -267,11 +296,8 @@ def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: # ``self.inner`` is a bound method of the instance, so the inherited # ``repr(self.inner)`` would recurse infinitely; the override keeps repr # finite and keyed on the name for schema-dump dedup. - assert repr(config_validation.bind_key) == "bind_key('Bind key')" - assert ( - repr(config_validation.bind_key(name="Decryption key")) - == "bind_key('Decryption key')" - ) + assert repr(cv.bind_key) == "bind_key('Bind key')" + assert repr(cv.bind_key(name="Decryption key")) == "bind_key('Decryption key')" def test_sensitive__repr_mirrors_inner() -> None: @@ -279,18 +305,14 @@ def test_sensitive__repr_mirrors_inner() -> None: # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers # interchangeable for that purpose and avoids leaking the wrapper as # noise in voluptuous error messages. - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.string - ) - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.sensitive(config_validation.string) - ) + assert repr(cv.sensitive(cv.string)) == repr(cv.string) + assert repr(cv.sensitive(cv.string)) == repr(cv.sensitive(cv.string)) def test_sensitive_key_fragments__covers_common_terms() -> None: - assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + assert isinstance(cv.SENSITIVE_KEY_FRAGMENTS, frozenset) for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): - assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + assert term in cv.SENSITIVE_KEY_FRAGMENTS @given( @@ -305,31 +327,31 @@ def test_sensitive_key_fragments__covers_common_terms() -> None: ) @example("") def test_icon__valid(value): - actual = config_validation.icon(value) + actual = cv.icon(value) assert actual == value def test_icon__invalid(): with pytest.raises(Invalid, match="Icons must match the format "): - config_validation.icon("foo") + cv.icon("foo") def test_icon__max_length(): """Test that icons exceeding 63 bytes are rejected.""" # Exactly 63 bytes should pass max_icon = "mdi:" + "a" * 59 # 63 bytes total - assert config_validation.icon(max_icon) == max_icon + assert cv.icon(max_icon) == max_icon # 64 bytes should fail too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): - config_validation.icon(too_long) + cv.icon(too_long) def test_byte_length() -> None: """Test ByteLength validator checks UTF-8 byte length, not char count.""" - validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + validator = cv.ByteLength(max=10) # pylint: disable=no-member # ASCII: 10 chars = 10 bytes, should pass assert validator("a" * 10) == "a" * 10 @@ -348,18 +370,18 @@ def test_byte_length() -> None: @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): - assert config_validation.boolean(value) is True + assert cv.boolean(value) is True @pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) def test_boolean__valid_false(value): - assert config_validation.boolean(value) is False + assert cv.boolean(value) is False @pytest.mark.parametrize("value", (None, 1, 0, "foo")) def test_boolean__invalid(value): with pytest.raises(Invalid, match="Expected boolean value"): - config_validation.boolean(value) + cv.boolean(value) # deadline disabled: the validator is trivially fast, but Hypothesis's per-example @@ -368,31 +390,31 @@ def test_boolean__invalid(value): @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): - config_validation.ipv4address(value) + cv.ipv4address(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) def test_ipv4__invalid(value): with pytest.raises(Invalid, match="is not a valid IPv4 address"): - config_validation.ipv4address(value) + cv.ipv4address(value) @settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): - config_validation.ipaddress(value) + cv.ipaddress(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) def test_ipv6__invalid(value): with pytest.raises(Invalid, match="is not a valid IP address"): - config_validation.ipaddress(value) + cv.ipaddress(value) # TODO: ensure_list @given(integers()) def hex_int__valid(value): - actual = config_validation.hex_int(value) + actual = cv.hex_int(value) assert isinstance(actual, HexInt) assert actual == value @@ -472,18 +494,14 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_h2_idf": "19", } - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.SplitDefault( + cv.SplitDefault( "full", **common_mappings, **idf_mappings, **arduino_mappings ): str, - config_validation.SplitDefault( - "idf", **common_mappings, **idf_mappings - ): str, - config_validation.SplitDefault( - "arduino", **common_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault("simple", **common_mappings): str, + cv.SplitDefault("idf", **common_mappings, **idf_mappings): str, + cv.SplitDefault("arduino", **common_mappings, **arduino_mappings): str, + cv.SplitDefault("simple", **common_mappings): str, } ) @@ -515,16 +533,16 @@ def test_require_framework_version(framework, platform, message): CORE.data[KEY_CORE] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(1, 0, 0) assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2040_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), extra_message="test 1", )("test") == "test" @@ -534,24 +552,24 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires at least framework version 2.0.0. test 2", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(2, 0, 0), - esp32_arduino=config_validation.Version(2, 0, 0), - esp8266_arduino=config_validation.Version(2, 0, 0), - rp2040_arduino=config_validation.Version(2, 0, 0), - bk72xx_arduino=config_validation.Version(2, 0, 0), - host=config_validation.Version(2, 0, 0), + cv.require_framework_version( + esp_idf=cv.Version(2, 0, 0), + esp32_arduino=cv.Version(2, 0, 0), + esp8266_arduino=cv.Version(2, 0, 0), + rp2040_arduino=cv.Version(2, 0, 0), + bk72xx_arduino=cv.Version(2, 0, 0), + host=cv.Version(2, 0, 0), extra_message="test 2", )("test") assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(1, 5, 0), - esp32_arduino=config_validation.Version(1, 5, 0), - esp8266_arduino=config_validation.Version(1, 5, 0), - rp2040_arduino=config_validation.Version(1, 5, 0), - bk72xx_arduino=config_validation.Version(1, 5, 0), - host=config_validation.Version(1, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(1, 5, 0), + esp32_arduino=cv.Version(1, 5, 0), + esp8266_arduino=cv.Version(1, 5, 0), + rp2040_arduino=cv.Version(1, 5, 0), + bk72xx_arduino=cv.Version(1, 5, 0), + host=cv.Version(1, 5, 0), max_version=True, extra_message="test 3", )("test") @@ -562,13 +580,13 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires framework version 0.5.0 or lower. test 4", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2040_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), max_version=True, extra_message="test 4", )("test") @@ -576,7 +594,7 @@ def test_require_framework_version(framework, platform, message): with pytest.raises( vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" ): - config_validation.require_framework_version( + cv.require_framework_version( extra_message="test 5", )("test") @@ -585,9 +603,9 @@ def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -599,9 +617,9 @@ def test_only_with_single_component_not_loaded() -> None: """Test OnlyWith with single component when component is not loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -613,11 +631,9 @@ def test_only_with_list_all_components_loaded() -> None: """Test OnlyWith with list when all components are loaded.""" CORE.loaded_integrations = {"zigbee", "nrf52"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -629,11 +645,9 @@ def test_only_with_list_partial_components_loaded() -> None: """Test OnlyWith with list when only some components are loaded.""" CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52 - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -645,11 +659,9 @@ def test_only_with_list_no_components_loaded() -> None: """Test OnlyWith with list when no components are loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -661,9 +673,9 @@ def test_only_with_list_multiple_components() -> None: """Test OnlyWith with list requiring three components.""" CORE.loaded_integrations = {"comp1", "comp2", "comp3"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( + cv.OnlyWith( "test_id", ["comp1", "comp2", "comp3"], default="test_value" ): str, } @@ -682,9 +694,9 @@ def test_only_with_empty_list() -> None: """Test OnlyWith with empty list (edge case).""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("test_id", [], default="test_value"): str, + cv.OnlyWith("test_id", [], default="test_value"): str, } ) @@ -697,9 +709,9 @@ def test_only_with_user_value_overrides_default() -> None: """Test OnlyWith respects user-provided values over defaults.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, } ) @@ -709,7 +721,7 @@ def test_only_with_user_value_overrides_default() -> None: @pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度")) def test_string_no_slash__valid(value: str) -> None: - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == value @@ -726,7 +738,7 @@ def test_string_no_slash__slash_replaced_with_warning( value: str, expected: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that '/' is auto-replaced with fraction slash and warning is logged.""" - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text assert "will become an error in ESPHome 2026.7.0" in caplog.text @@ -735,16 +747,16 @@ def test_string_no_slash__slash_replaced_with_warning( def test_string_no_slash__long_string_allowed() -> None: # string_no_slash doesn't enforce length - use cv.Length() separately long_value = "x" * 200 - assert config_validation.string_no_slash(long_value) == long_value + assert cv.string_no_slash(long_value) == long_value def test_string_no_slash__empty() -> None: - assert config_validation.string_no_slash("") == "" + assert cv.string_no_slash("") == "" @pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器")) def test_validate_entity_name__valid(value: str) -> None: - actual = config_validation._validate_entity_name(value) + actual = cv._validate_entity_name(value) assert actual == value @@ -752,40 +764,40 @@ def test_validate_entity_name__slash_replaced_with_warning( caplog: pytest.LogCaptureFixture, ) -> None: """Test that '/' in entity names is auto-replaced with fraction slash.""" - actual = config_validation._validate_entity_name("has/slash") + actual = cv._validate_entity_name("has/slash") assert actual == "has⁄slash" assert "reserved as a URL path separator" in caplog.text def test_validate_entity_name__max_length() -> None: # 120 bytes should pass - assert config_validation._validate_entity_name("x" * 120) == "x" * 120 + assert cv._validate_entity_name("x" * 120) == "x" * 120 # 121 bytes should fail with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): - config_validation._validate_entity_name("x" * 121) + cv._validate_entity_name("x" * 121) def test_validate_entity_name__multibyte_byte_length() -> None: # 40 chars of 3-byte UTF-8 = 120 bytes, should pass - assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + assert cv._validate_entity_name("温" * 40) == "温" * 40 # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): - config_validation._validate_entity_name("温" * 41) + cv._validate_entity_name("温" * 41) def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None with pytest.raises(Invalid, match="friendly_name is not set"): - config_validation._validate_entity_name("None") + cv._validate_entity_name("None") def test_validate_entity_name__none_with_friendly_name() -> None: # When name is "None" but friendly_name is set, it should return None CORE.friendly_name = "My Device" - result = config_validation._validate_entity_name("None") + result = cv._validate_entity_name("None") assert result is None CORE.friendly_name = None # Reset @@ -808,7 +820,7 @@ def test_validate_entity_name__none_with_friendly_name() -> None: ), ) def test_percentage__valid(value: object, expected: float) -> None: - assert config_validation.percentage(value) == expected + assert cv.percentage(value) == expected @pytest.mark.parametrize( @@ -826,7 +838,7 @@ def test_percentage__valid(value: object, expected: float) -> None: ) def test_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.percentage(value) + cv.percentage(value) @pytest.mark.parametrize( @@ -845,7 +857,7 @@ def test_percentage__invalid(value: object) -> None: ), ) def test_possibly_negative_percentage__valid(value: object, expected: float) -> None: - assert config_validation.possibly_negative_percentage(value) == expected + assert cv.possibly_negative_percentage(value) == expected @pytest.mark.parametrize( @@ -861,7 +873,7 @@ def test_possibly_negative_percentage__valid(value: object, expected: float) -> ) def test_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.possibly_negative_percentage(value) + cv.possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -878,7 +890,7 @@ def test_possibly_negative_percentage__invalid(value: object) -> None: ), ) def test_unbounded_percentage__valid(value: object, expected: float) -> None: - assert config_validation.unbounded_percentage(value) == expected + assert cv.unbounded_percentage(value) == expected @pytest.mark.parametrize( @@ -893,7 +905,7 @@ def test_unbounded_percentage__valid(value: object, expected: float) -> None: ) def test_unbounded_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) @pytest.mark.parametrize( @@ -916,13 +928,13 @@ def test_unbounded_percentage__invalid(value: object) -> None: def test_unbounded_possibly_negative_percentage__valid( value: object, expected: float ) -> None: - assert config_validation.unbounded_possibly_negative_percentage(value) == expected + assert cv.unbounded_possibly_negative_percentage(value) == expected @pytest.mark.parametrize("value", ("foo", None)) def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -934,9 +946,9 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( ) -> None: """Raw numeric values outside [-1, 1] must use a percent sign.""" with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) def test_update_interval__coerces_zero_to_one_ms( @@ -947,7 +959,7 @@ def test_update_interval__coerces_zero_to_one_ms( existing configs compiling on upgrade while emitting a user-facing warning that directs them to set a non-zero value.""" with caplog.at_level("WARNING"): - result = config_validation.update_interval("0ms") + result = cv.update_interval("0ms") assert result.total_milliseconds == 1 assert "update_interval of 0ms is not supported" in caplog.text assert "1ms" in caplog.text @@ -955,14 +967,14 @@ def test_update_interval__coerces_zero_to_one_ms( def test_update_interval__preserves_nonzero_values() -> None: """Non-zero update_interval values must pass through unchanged.""" - assert config_validation.update_interval("1ms").total_milliseconds == 1 - assert config_validation.update_interval("50ms").total_milliseconds == 50 - assert config_validation.update_interval("60s").total_milliseconds == 60000 + assert cv.update_interval("1ms").total_milliseconds == 1 + assert cv.update_interval("50ms").total_milliseconds == 50 + assert cv.update_interval("60s").total_milliseconds == 60000 def test_update_interval__never_passes_through() -> None: """update_interval: never must still map to SCHEDULER_DONT_RUN.""" - result = config_validation.update_interval("never") + result = cv.update_interval("never") assert result.total_milliseconds == SCHEDULER_DONT_RUN @@ -978,24 +990,20 @@ def test_optional_default_visibility_is_none() -> None: access; absence (``None``) means "render on the editor's main form." """ - o = config_validation.Optional("foo") + o = cv.Optional("foo") assert o.visibility is None def test_optional_visibility_advanced() -> None: """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert o.visibility is config_validation.Visibility.ADVANCED + o = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) + assert o.visibility is cv.Visibility.ADVANCED def test_optional_visibility_yaml_only() -> None: """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.YAML_ONLY - ) - assert o.visibility is config_validation.Visibility.YAML_ONLY + o = cv.Optional("foo", visibility=cv.Visibility.YAML_ONLY) + assert o.visibility is cv.Visibility.YAML_ONLY def test_visibility_str_values_match_dump_emission() -> None: @@ -1007,8 +1015,8 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ - assert str(config_validation.Visibility.ADVANCED) == "advanced" - assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only" + assert str(cv.Visibility.ADVANCED) == "advanced" + assert str(cv.Visibility.YAML_ONLY) == "yaml_only" def test_optional_visibility_does_not_affect_validation() -> None: @@ -1016,16 +1024,14 @@ def test_optional_visibility_does_not_affect_validation() -> None: validator behaves. A schema with ``visibility`` applied must accept and reject the same values it would without it. """ - plain = config_validation.Schema( - {config_validation.Optional("foo", default=42): config_validation.int_} - ) - flagged = config_validation.Schema( + plain = cv.Schema({cv.Optional("foo", default=42): cv.int_}) + flagged = cv.Schema( { - config_validation.Optional( + cv.Optional( "foo", default=42, - visibility=config_validation.Visibility.YAML_ONLY, - ): config_validation.int_ + visibility=cv.Visibility.YAML_ONLY, + ): cv.int_ } ) # Same accept / default-fill behavior. @@ -1040,7 +1046,7 @@ def test_optional_visibility_does_not_affect_validation() -> None: def test_required_default_visibility_is_none() -> None: """``Required`` mirrors ``Optional`` for the ``visibility`` kwarg.""" - r = config_validation.Required("foo") + r = cv.Required("foo") assert r.visibility is None @@ -1050,10 +1056,8 @@ def test_required_visibility_kwarg() -> None: Required fields rarely need the kwarg, but exposing it lets consumers apply uniform logic across key markers. """ - r = config_validation.Required( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert r.visibility is config_validation.Visibility.ADVANCED + r = cv.Required("foo", visibility=cv.Visibility.ADVANCED) + assert r.visibility is cv.Visibility.ADVANCED def test_polling_component_schema_visibility_opt_in() -> None: @@ -1062,28 +1066,17 @@ def test_polling_component_schema_visibility_opt_in() -> None: Time platforms pass ``Visibility.ADVANCED``; sensors and other polling components leave it ``None`` and keep the un-flagged shape. """ - default = config_validation.polling_component_schema("15min") - advanced = config_validation.polling_component_schema( - "15min", visibility=config_validation.Visibility.ADVANCED - ) + default = cv.polling_component_schema("15min") + advanced = cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED) default_keys = {str(k): k for k in default.schema} advanced_keys = {str(k): k for k in advanced.schema} assert default_keys["update_interval"].visibility is None - assert ( - advanced_keys["update_interval"].visibility - is config_validation.Visibility.ADVANCED - ) + assert advanced_keys["update_interval"].visibility is cv.Visibility.ADVANCED # The opt-in only touches update_interval — setup_priority # still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA # in both shapes. - assert ( - default_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) - assert ( - advanced_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) + assert default_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY + assert advanced_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY def test_polling_component_schema_no_default_ignores_visibility() -> None: @@ -1096,11 +1089,9 @@ def test_polling_component_schema_no_default_ignores_visibility() -> None: required field. The helper accepts the kwarg unconditionally for caller ergonomics but doesn't honour it on this branch. """ - schema = config_validation.polling_component_schema( - None, visibility=config_validation.Visibility.ADVANCED - ) + schema = cv.polling_component_schema(None, visibility=cv.Visibility.ADVANCED) keys = {str(k): k for k in schema.schema} - assert isinstance(keys["update_interval"], config_validation.Required) + assert isinstance(keys["update_interval"], cv.Required) assert keys["update_interval"].visibility is None @@ -1123,28 +1114,1517 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: detail this test deliberately doesn't pin, since it's a consumer concern). """ - inner_unset = config_validation.Optional("baz") - inner_yaml_only = config_validation.Optional( - "qux", visibility=config_validation.Visibility.YAML_ONLY - ) - parent = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) + inner_unset = cv.Optional("baz") + inner_yaml_only = cv.Optional("qux", visibility=cv.Visibility.YAML_ONLY) + parent = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) # Wire them into a nested schema — none of the markers' own # ``visibility`` should change as a result. - schema = config_validation.Schema( + schema = cv.Schema( { - parent: config_validation.Schema( + parent: cv.Schema( { - inner_unset: config_validation.int_, - inner_yaml_only: config_validation.string, + inner_unset: cv.int_, + inner_yaml_only: cv.string, } ) } ) assert schema # touch the schema so any deferred mutation runs - assert parent.visibility is config_validation.Visibility.ADVANCED + assert parent.visibility is cv.Visibility.ADVANCED assert inner_unset.visibility is None - assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY + assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY + + +def _wrap_str(value: str) -> ESPHomeDataBase: + """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" + return make_data_base(value) + + +def _set_core_target(platform: str, framework: str) -> None: + """Set CORE target platform/framework for validators that depend on them.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + +def _set_framework_version(platform: str, framework: str, version: cv.Version) -> None: + """Set CORE target platform/framework and framework version.""" + _set_core_target(platform, framework) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = version + + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- + + +def test_version_str_with_extra() -> None: + assert str(cv.Version(1, 2, 3, "b1")) == "1.2.3-b1" + + +def test_version_str_without_extra() -> None: + assert str(cv.Version(1, 2, 3)) == "1.2.3" + + +def test_version_parse_valid() -> None: + version = cv.Version.parse("2024.5.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 2024, + 5, + 1, + "", + ) + + +def test_version_parse_with_extra() -> None: + version = cv.Version.parse("2024.5.1-dev20240101") + assert version.extra == "dev20240101" + + +def test_version_parse_invalid() -> None: + with pytest.raises(ValueError, match="Not a valid version number"): + cv.Version.parse("not.a.version") + + +def test_version_is_beta() -> None: + assert cv.Version.parse("2024.5.0b1").is_beta is True + assert cv.Version.parse("2024.5.0").is_beta is False + + +def test_version_is_dev() -> None: + assert cv.Version.parse("2024.5.0-dev").is_dev is True + assert cv.Version.parse("2024.5.0").is_dev is False + + +# --------------------------------------------------------------------------- +# alphanumeric / valid_name / validate_id_name +# --------------------------------------------------------------------------- + + +def test_alphanumeric_none() -> None: + with pytest.raises(Invalid, match="string value is None"): + cv.alphanumeric(None) + + +def test_valid_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.valid_name("plainname") == "plainname" + + +def test_validate_id_name_empty() -> None: + with pytest.raises(Invalid, match="ID must not be empty"): + cv.validate_id_name("") + + +def test_validate_id_name_digit_first() -> None: + with pytest.raises(Invalid, match="First character in ID cannot be a digit"): + cv.validate_id_name("1abc") + + +def test_validate_id_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.validate_id_name("validid") == "validid" + + +def test_validate_id_name_reserved() -> None: + with pytest.raises(Invalid, match="reserved internally"): + cv.validate_id_name("alarm") + + +def test_validate_id_name_integration_conflict() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises( + Invalid, match="conflicts with the name of an esphome integration" + ): + cv.validate_id_name("mqtt") + + +# --------------------------------------------------------------------------- +# sub_device_id +# --------------------------------------------------------------------------- + + +def test_sub_device_id_schema_extract() -> None: + from esphome.core.config import Device + + assert cv.sub_device_id(SCHEMA_EXTRACT) is Device + + +def test_sub_device_id_empty() -> None: + assert cv.sub_device_id(None) is None + assert cv.sub_device_id("") is None + + +def test_sub_device_id_valid() -> None: + result = cv.sub_device_id("my_device") + assert isinstance(result, ID) + assert result.id == "my_device" + + +# --------------------------------------------------------------------------- +# boolean_false / ensure_list +# --------------------------------------------------------------------------- + + +def test_boolean_false_valid() -> None: + assert cv.boolean_false(False) is False + assert cv.boolean_false("no") is False + + +def test_boolean_false_invalid() -> None: + with pytest.raises(Invalid, match="Expected boolean value to be false"): + cv.boolean_false(True) + + +def test_ensure_list_none() -> None: + assert cv.ensure_list(cv.int_)(None) == [] + + +def test_ensure_list_empty_dict() -> None: + assert cv.ensure_list(cv.int_)({}) == [] + + +def test_ensure_list_single_value() -> None: + assert cv.ensure_list(cv.int_)(5) == [5] + + +def test_ensure_list_actual_list() -> None: + assert cv.ensure_list(cv.int_)([1, 2, 3]) == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# hex_int / int_to_hex_string / int_ +# --------------------------------------------------------------------------- + + +def test_hex_int() -> None: + result = cv.hex_int(255) + assert result == 255 + assert isinstance(result, HexInt) + + +def test_int_to_hex_string_int() -> None: + assert cv.int_to_hex_string(64) == "0x40" + + +def test_int_to_hex_string_passthrough() -> None: + assert cv.int_to_hex_string("already") == "already" + + +def test_int_float_whole() -> None: + assert cv.int_(5.0) == 5 + + +def test_int_float_fractional() -> None: + with pytest.raises(Invalid, match="only accepts integers with no fractional part"): + cv.int_(5.5) + + +def test_int_hex_string() -> None: + assert cv.int_("0xFF") == 255 + + +# --------------------------------------------------------------------------- +# int_range / float_range no-min branches +# --------------------------------------------------------------------------- + + +def test_int_range_no_min() -> None: + validator = cv.int_range(max=10) + assert validator(5) == 5 + + +def test_float_range_no_min() -> None: + validator = cv.float_range(max=10.0) + assert validator(5.0) == 5.0 + + +# --------------------------------------------------------------------------- +# use_id / declare_id / templatable +# --------------------------------------------------------------------------- + + +def test_use_id_schema_extract() -> None: + assert cv.use_id(int)(SCHEMA_EXTRACT) is int + + +def test_use_id_none() -> None: + result = cv.use_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is False + + +def test_use_id_existing_id_passthrough() -> None: + existing = ID("foo", is_declaration=False, type=int) + assert cv.use_id(int)(existing) is existing + + +def test_use_id_from_string() -> None: + result = cv.use_id(int)("foo") + assert isinstance(result, ID) + assert result.id == "foo" + assert result.is_declaration is False + + +def test_declare_id_schema_extract() -> None: + assert cv.declare_id(int)(SCHEMA_EXTRACT) is int + + +def test_declare_id_none() -> None: + result = cv.declare_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is True + + +def test_declare_id_from_string() -> None: + result = cv.declare_id(int)("foo") + assert result.id == "foo" + assert result.is_declaration is True + + +def test_templatable_schema_extract() -> None: + assert cv.templatable(cv.int_)(SCHEMA_EXTRACT) is cv.int_ + + +def test_templatable_lambda() -> None: + result = cv.templatable(cv.int_)(Lambda("return 5;")) + assert isinstance(result, Lambda) + + +def test_templatable_plain_value() -> None: + assert cv.templatable(cv.int_)(5) == 5 + + +def test_templatable_dict_validators() -> None: + validator = cv.templatable({cv.Required("x"): cv.int_}) + assert validator({"x": 5}) == {"x": 5} + + +# --------------------------------------------------------------------------- +# only_on / only_with_framework +# --------------------------------------------------------------------------- + + +def test_only_on_list_platform_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]) + assert validator("x") == "x" + + +def test_only_on_wrong_platform() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + validator = cv.only_on(PLATFORM_ESP32) + with pytest.raises(Invalid, match="only available on"): + validator("x") + + +def test_only_with_framework_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_with_framework([Framework.ARDUINO]) + assert validator("x") == "x" + + +def test_only_with_framework_mismatch_with_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", "some/path")}, + ) + with pytest.raises(Invalid, match="some/path"): + validator("x") + + +def test_only_with_framework_mismatch_no_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework(Framework.ARDUINO) + with pytest.raises(Invalid, match="only available with framework"): + validator("x") + + +def test_only_with_framework_suggestion_without_docs_path() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", None)}, + ) + with pytest.raises(Invalid, match="Please use 'some_component'"): + validator("x") + + +# --------------------------------------------------------------------------- +# has_*_key helpers +# --------------------------------------------------------------------------- + + +def test_has_at_least_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_least_one_key("a", "b")([]) + + +def test_has_at_least_one_key_none() -> None: + with pytest.raises(Invalid, match="at least one of"): + cv.has_at_least_one_key("a", "b")({"c": 1}) + + +def test_has_at_least_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_least_one_key("a", "b")(obj) is obj + + +def test_has_exactly_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_exactly_one_key("a", "b")("notdict") + + +def test_has_exactly_one_key_too_many() -> None: + with pytest.raises(Invalid, match="Cannot specify more than one"): + cv.has_exactly_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_exactly_one_key_too_few() -> None: + with pytest.raises(Invalid, match="Must contain exactly one"): + cv.has_exactly_one_key("a", "b")({"c": 1}) + + +def test_has_exactly_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_exactly_one_key("a", "b")(obj) is obj + + +def test_has_at_most_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_most_one_key("a", "b")(5) + + +def test_has_at_most_one_key_too_many() -> None: + with pytest.raises(vol.MultipleInvalid, match="Cannot specify more than one"): + cv.has_at_most_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_at_most_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_most_one_key("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_none_or_all_keys("a", "b")(5) + + +def test_has_none_or_all_keys_partial() -> None: + with pytest.raises(Invalid, match="none or all"): + cv.has_none_or_all_keys("a", "b")({"a": 1}) + + +def test_has_none_or_all_keys_all() -> None: + obj = {"a": 1, "b": 2} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_none() -> None: + obj = {"c": 3} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +# --------------------------------------------------------------------------- +# time_period_str_colon / time_period_str_unit +# --------------------------------------------------------------------------- + + +def test_time_period_str_colon_int() -> None: + with pytest.raises(Invalid, match="wrap time values in quotes"): + cv.time_period_str_colon(5) + + +def test_time_period_str_colon_not_str() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon([1, 2]) + + +def test_time_period_str_colon_bad_value() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("aa:bb") + + +def test_time_period_str_colon_hh_mm() -> None: + assert cv.time_period_str_colon("01:30") == TimePeriod(hours=1, minutes=30) + + +def test_time_period_str_colon_hh_mm_ss() -> None: + assert cv.time_period_str_colon("01:30:15") == TimePeriod( + hours=1, minutes=30, seconds=15 + ) + + +def test_time_period_str_colon_too_many_parts() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("1:2:3:4") + + +def test_time_period_str_unit_int() -> None: + with pytest.raises(Invalid, match=r"no time \*unit\*"): + cv.time_period_str_unit(5) + + +def test_time_period_str_unit_timeperiod_input() -> None: + assert cv.time_period_str_unit(TimePeriod(seconds=5)) == TimePeriod(seconds=5) + + +def test_time_period_str_unit_not_str() -> None: + with pytest.raises(Invalid, match="Expected string for time period"): + cv.time_period_str_unit([1]) + + +def test_time_period_str_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected time period with unit"): + cv.time_period_str_unit("5/3") + + +def test_time_period_str_unit_empty_mantissa() -> None: + with pytest.raises(Invalid): + cv.time_period_str_unit("s") + + +# --------------------------------------------------------------------------- +# time_period_in_* converters +# --------------------------------------------------------------------------- + + +def test_time_period_in_milliseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is milliseconds"): + cv.time_period_in_milliseconds_(TimePeriod(microseconds=5)) + + +def test_time_period_in_microseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is microseconds"): + cv.time_period_in_microseconds_(TimePeriod(nanoseconds=5)) + + +def test_time_period_in_microseconds_ok() -> None: + assert cv.time_period_in_microseconds_( + TimePeriod(microseconds=5) + ) == TimePeriodMicroseconds(microseconds=5) + + +def test_time_period_in_nanoseconds_ok() -> None: + assert cv.time_period_in_nanoseconds_( + TimePeriod(nanoseconds=5) + ) == TimePeriodNanoseconds(nanoseconds=5) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + ], +) +def test_time_period_in_seconds_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is seconds"): + cv.time_period_in_seconds_(value) + + +def test_time_period_in_seconds_ok() -> None: + assert cv.time_period_in_seconds_(TimePeriod(seconds=5)) == TimePeriodSeconds( + seconds=5 + ) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + TimePeriod(seconds=1), + ], +) +def test_time_period_in_minutes_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is minutes"): + cv.time_period_in_minutes_(value) + + +def test_time_period_in_minutes_ok() -> None: + assert cv.time_period_in_minutes_(TimePeriod(minutes=5)) == TimePeriodMinutes( + minutes=5 + ) + + +# --------------------------------------------------------------------------- +# time_of_day / date_time +# --------------------------------------------------------------------------- + + +def test_time_of_day_valid() -> None: + assert cv.time_of_day("12:34:56") == { + CONF_HOUR: 12, + CONF_MINUTE: 34, + CONF_SECOND: 56, + } + + +def test_date_time_dict_input() -> None: + validator = cv.date_time(date=True, time=False) + result = validator({CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1}) + assert result[CONF_YEAR] == 2024 + + +def test_date_time_date_only_string() -> None: + validator = cv.date_time(date=True, time=False) + assert validator("2024-5-1") == {CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1} + + +def test_date_time_date_and_time_string() -> None: + validator = cv.date_time(date=True, time=True) + result = validator("2024-05-01 13:30:00") + assert result[CONF_HOUR] == 13 + assert result[CONF_YEAR] == 2024 + + +def test_date_time_invalid_format() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("notatime") + + +def test_date_time_ampm() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("1:30 PM")[CONF_HOUR] == 13 + + +def test_date_time_no_seconds() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("13:30")[CONF_SECOND] == 0 + + +def test_date_time_strptime_error() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("25:99") + + +# --------------------------------------------------------------------------- +# mac_address / uuid +# --------------------------------------------------------------------------- + + +def test_mac_address_valid() -> None: + result = cv.mac_address("AA:BB:CC:DD:EE:FF") + assert isinstance(result, MACAddress) + + +def test_mac_address_wrong_parts() -> None: + with pytest.raises(Invalid, match="6 : .colon. separated parts"): + cv.mac_address("AA:BB:CC") + + +def test_mac_address_wrong_length() -> None: + with pytest.raises(Invalid, match="format XX:XX"): + cv.mac_address("A:BB:CC:DD:EE:FF") + + +def test_mac_address_non_hex() -> None: + with pytest.raises(Invalid, match="hexadecimal values"): + cv.mac_address("GG:BB:CC:DD:EE:FF") + + +def test_uuid_valid() -> None: + result = cv.uuid("12345678-1234-5678-1234-567812345678") + assert str(result) == "12345678-1234-5678-1234-567812345678" + + +# --------------------------------------------------------------------------- +# float_with_unit family +# --------------------------------------------------------------------------- + + +def test_float_with_unit_optional_unit_plain_float() -> None: + assert cv.angle("1.5") == 1.5 + + +def test_float_with_unit_optional_unit_with_suffix() -> None: + assert cv.angle("45deg") == 45.0 + + +def test_float_with_unit_with_suffix() -> None: + assert cv.frequency("10kHz") == 10000.0 + + +def test_float_with_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected frequency with unit"): + cv.frequency("!!") + + +def test_float_with_unit_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid frequency suffix"): + cv.frequency("10xHz") + + +def test_temperature_celsius() -> None: + assert cv.temperature("25°C") == 25.0 + + +def test_temperature_kelvin() -> None: + assert cv.temperature("300K") == pytest.approx(300 - 273.15) + + +def test_temperature_fahrenheit() -> None: + assert cv.temperature("32°F") == pytest.approx(0.0) + + +def test_temperature_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature("5x") + + +def test_temperature_delta_celsius() -> None: + assert cv.temperature_delta("5°C") == 5.0 + + +def test_temperature_delta_kelvin() -> None: + assert cv.temperature_delta("5K") == 5.0 + + +def test_temperature_delta_fahrenheit() -> None: + assert cv.temperature_delta("9°F") == pytest.approx(5.0) + + +def test_temperature_delta_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature_delta("5x") + + +def test_color_temperature_mireds() -> None: + assert cv.color_temperature("153 mireds") == pytest.approx(153.0) + + +def test_color_temperature_kelvin() -> None: + assert cv.color_temperature("6536 K") == pytest.approx(1000000.0 / 6536) + + +def test_color_temperature_negative() -> None: + with pytest.raises(Invalid, match="cannot be negative"): + cv.color_temperature("-1 mireds") + + +# --------------------------------------------------------------------------- +# validate_bytes +# --------------------------------------------------------------------------- + + +def test_validate_bytes_plain() -> None: + assert cv.validate_bytes("100") == 100 + + +def test_validate_bytes_with_unit() -> None: + assert cv.validate_bytes("2kB") == 2000 + + +def test_validate_bytes_no_match() -> None: + with pytest.raises(Invalid, match="Expected number of bytes"): + cv.validate_bytes("abc") + + +def test_validate_bytes_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid metric suffix"): + cv.validate_bytes("5xx") + + +def test_validate_bytes_negative_exponent() -> None: + with pytest.raises(Invalid, match="positive exponents"): + cv.validate_bytes("5m") + + +# --------------------------------------------------------------------------- +# hostname / domain / domain_name / ssid +# --------------------------------------------------------------------------- + + +def test_hostname_valid() -> None: + assert cv.hostname("my-host01") == "my-host01" + + +def test_hostname_invalid() -> None: + with pytest.raises(Invalid, match="Invalid hostname"): + cv.hostname("invalid_host!") + + +def test_domain_valid_name() -> None: + assert cv.domain("example.com") == "example.com" + + +def test_domain_ip_fallback() -> None: + assert cv.domain("::1") == "::1" + + +def test_domain_invalid() -> None: + with pytest.raises(Invalid, match="Invalid domain"): + cv.domain("::not::valid::") + + +def test_domain_name_empty() -> None: + assert cv.domain_name("") == "" + + +def test_domain_name_valid() -> None: + assert cv.domain_name(".local") == ".local" + + +def test_domain_name_no_leading_dot() -> None: + with pytest.raises(Invalid, match="must start with"): + cv.domain_name("local") + + +def test_domain_name_double_dot() -> None: + with pytest.raises(Invalid, match="single"): + cv.domain_name("..local") + + +def test_domain_name_invalid_char() -> None: + with pytest.raises(Invalid, match="alphanumeric"): + cv.domain_name(".local!") + + +def test_ssid_valid() -> None: + assert cv.ssid("MyNetwork") == "MyNetwork" + + +def test_ssid_empty() -> None: + with pytest.raises(Invalid, match="can't be empty"): + cv.ssid("") + + +def test_ssid_too_long() -> None: + with pytest.raises(Invalid, match="longer than 32"): + cv.ssid("x" * 33) + + +# --------------------------------------------------------------------------- +# IP address / network validators +# --------------------------------------------------------------------------- + + +def test_ipv6address_valid() -> None: + assert str(cv.ipv6address("::1")) == "::1" + + +def test_ipv6address_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 address"): + cv.ipv6address("not-ipv6") + + +def test_ipv4address_multi_broadcast_multicast() -> None: + assert str(cv.ipv4address_multi_broadcast("224.0.0.1")) == "224.0.0.1" + + +def test_ipv4address_multi_broadcast_broadcast() -> None: + assert str(cv.ipv4address_multi_broadcast("255.255.255.255")) == "255.255.255.255" + + +def test_ipv4address_multi_broadcast_invalid() -> None: + with pytest.raises(Invalid, match="not a multicasst"): + cv.ipv4address_multi_broadcast("192.168.0.1") + + +def test_ipv4network_valid() -> None: + assert str(cv.ipv4network("192.168.0.0/24")) == "192.168.0.0/24" + + +def test_ipv4network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv4 network"): + cv.ipv4network("notanetwork") + + +def test_ipv6network_valid() -> None: + assert str(cv.ipv6network("2001:db8::/32")) == "2001:db8::/32" + + +def test_ipv6network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 network"): + cv.ipv6network("notanetwork") + + +def test_ipnetwork_valid() -> None: + assert str(cv.ipnetwork("10.0.0.0/8")) == "10.0.0.0/8" + + +def test_ipnetwork_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IP network"): + cv.ipnetwork("notanetwork") + + +# --------------------------------------------------------------------------- +# MQTT topic validators +# --------------------------------------------------------------------------- + + +def test_valid_topic_none() -> None: + assert cv._valid_topic(None) == "" + + +def test_valid_topic_dict() -> None: + with pytest.raises(Invalid, match="dictionary with topic"): + cv._valid_topic({"a": 1}) + + +def test_valid_topic_unicode_error() -> None: + with pytest.raises(Invalid, match="valid UTF-8"): + cv._valid_topic("\ud800") + + +def test_valid_topic_empty() -> None: + with pytest.raises(Invalid, match="must not be empty"): + cv._valid_topic("") + + +def test_valid_topic_too_long() -> None: + with pytest.raises(Invalid, match="not be longer than 65535"): + cv._valid_topic("x" * 65536) + + +def test_valid_topic_null_char() -> None: + with pytest.raises(Invalid, match="null character"): + cv._valid_topic("a\0b") + + +def test_subscribe_topic_valid() -> None: + assert cv.subscribe_topic("home/+/temp") == "home/+/temp" + + +def test_subscribe_topic_multilevel() -> None: + assert cv.subscribe_topic("home/#") == "home/#" + + +def test_subscribe_topic_bad_plus() -> None: + with pytest.raises(Invalid, match="Single-level wildcard"): + cv.subscribe_topic("home/a+/temp") + + +def test_subscribe_topic_hash_not_last() -> None: + with pytest.raises(Invalid, match="Multi-level wildcard must be the last"): + cv.subscribe_topic("home/#/temp") + + +def test_subscribe_topic_hash_not_after_separator() -> None: + with pytest.raises(Invalid, match="must be after a topic level separator"): + cv.subscribe_topic("home#") + + +def test_publish_topic_valid() -> None: + assert cv.publish_topic("home/temp") == "home/temp" + + +def test_publish_topic_wildcard() -> None: + with pytest.raises(Invalid, match="Wildcards can not be used"): + cv.publish_topic("home/+") + + +def test_mqtt_payload_none() -> None: + assert cv.mqtt_payload(None) == "" + + +def test_mqtt_payload_value() -> None: + assert cv.mqtt_payload("hello") == "hello" + + +def test_mqtt_qos_valid() -> None: + assert cv.mqtt_qos("1") == 1 + + +def test_mqtt_qos_not_int() -> None: + with pytest.raises(Invalid, match="must be integer"): + cv.mqtt_qos("abc") + + +def test_mqtt_qos_out_of_range() -> None: + with pytest.raises(Invalid): + cv.mqtt_qos(5) + + +# --------------------------------------------------------------------------- +# requires_component / conflicts_with_component +# --------------------------------------------------------------------------- + + +def test_requires_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + assert cv.requires_component("mqtt")("x") == "x" + + +def test_requires_component_not_loaded() -> None: + CORE.loaded_integrations = set() + with pytest.raises(Invalid, match="requires component mqtt"): + cv.requires_component("mqtt")("x") + + +def test_conflicts_with_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises(Invalid, match="not compatible with component mqtt"): + cv.conflicts_with_component("mqtt")("x") + + +def test_conflicts_with_component_not_loaded() -> None: + CORE.loaded_integrations = set() + assert cv.conflicts_with_component("mqtt")("x") == "x" + + +# --------------------------------------------------------------------------- +# percentage_int / invalid / valid +# --------------------------------------------------------------------------- + + +def test_percentage_int_with_percent() -> None: + assert cv.percentage_int("50%") == 50 + + +def test_percentage_int_plain() -> None: + assert cv.percentage_int(50) == 50 + + +def test_invalid_always_raises() -> None: + with pytest.raises(Invalid, match="my message"): + cv.invalid("my message")("anything") + + +def test_valid_returns_value() -> None: + obj = object() + assert cv.valid(obj) is obj + + +# --------------------------------------------------------------------------- +# prepend_path / remove_prepend_path +# --------------------------------------------------------------------------- + + +def test_prepend_path_single() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path("foo"): + raise Invalid("bad") + assert list(exc_info.value.path) == ["foo"] + + +def test_prepend_path_list() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path(["a", "b"]): + raise Invalid("bad") + assert list(exc_info.value.path) == ["a", "b"] + + +def test_remove_prepend_path_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path(["a"]): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["b"] + + +def test_remove_prepend_path_non_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path("x"): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["a", "b"] + + +# --------------------------------------------------------------------------- +# one_of / enum +# --------------------------------------------------------------------------- + + +def test_one_of_extra_kwargs() -> None: + with pytest.raises(ValueError): + cv.one_of(1, 2, bogus=True) + + +def test_one_of_schema_extract() -> None: + assert cv.one_of("a", "b")(SCHEMA_EXTRACT) == ("a", "b") + + +def test_one_of_string_and_space() -> None: + assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b" + + +def test_one_of_int() -> None: + assert cv.one_of(1, 2, int=True)("2") == 2 + + +def test_one_of_float() -> None: + assert cv.one_of(1.0, 2.0, float=True)("2.0") == 2.0 + + +def test_one_of_lower() -> None: + assert cv.one_of("abc", lower=True)("ABC") == "abc" + + +def test_one_of_upper() -> None: + assert cv.one_of("ABC", upper=True)("abc") == "ABC" + + +def test_one_of_unknown_with_suggestion() -> None: + with pytest.raises(Invalid, match="did you mean"): + cv.one_of("apple", "banana")("aple") + + +def test_one_of_unknown_no_suggestion() -> None: + with pytest.raises(Invalid, match="valid options are"): + cv.one_of("apple", "banana")("zzzzzz") + + +def test_enum_schema_extract() -> None: + mapping = {"a": 1, "b": 2} + assert cv.enum(mapping)(SCHEMA_EXTRACT) == mapping + + +def test_enum_valid() -> None: + mapping = {"a": 10, "b": 20} + result = cv.enum(mapping)("a") + assert result == "a" + assert result.enum_value == 10 + + +# --------------------------------------------------------------------------- +# lambda_ / returning_lambda +# --------------------------------------------------------------------------- + + +def test_lambda_from_string() -> None: + result = cv.lambda_(_wrap_str("return 5;")) + assert isinstance(result, Lambda) + assert result.value == "return 5;" + + +def test_lambda_existing_lambda() -> None: + lam = Lambda("x") + assert cv.lambda_(lam) is lam + + +def test_lambda_entity_id_reference() -> None: + with pytest.raises(Invalid, match="entity-id-style ID"): + cv.lambda_(Lambda("return id(light.living_room);")) + + +def test_returning_lambda_valid() -> None: + assert isinstance(cv.returning_lambda(_wrap_str("return 5;")), Lambda) + + +def test_returning_lambda_no_return() -> None: + with pytest.raises(Invalid, match="return statement"): + cv.returning_lambda(Lambda("int x = 5;")) + + +# --------------------------------------------------------------------------- +# dimensions +# --------------------------------------------------------------------------- + + +def test_dimensions_list_valid() -> None: + assert cv.dimensions([320, 240]) == [320, 240] + + +def test_dimensions_list_wrong_length() -> None: + with pytest.raises(Invalid, match="length of two"): + cv.dimensions([1, 2, 3]) + + +def test_dimensions_list_non_int() -> None: + with pytest.raises(Invalid, match="must be integers"): + cv.dimensions(["a", "b"]) + + +def test_dimensions_list_non_positive() -> None: + with pytest.raises(Invalid, match="at least be 1"): + cv.dimensions([0, 240]) + + +def test_dimensions_string_valid() -> None: + assert cv.dimensions("320x240") == [320, 240] + + +def test_dimensions_number_invalid() -> None: + with pytest.raises(Invalid, match="must be a string"): + cv.dimensions(320) + + +def test_dimensions_string_invalid() -> None: + with pytest.raises(Invalid, match="Only WIDTHxHEIGHT"): + cv.dimensions("notdimensions") + + +# --------------------------------------------------------------------------- +# entity_id +# --------------------------------------------------------------------------- + + +def test_entity_id_valid() -> None: + assert cv.entity_id("Light.Living_Room") == "light.living_room" + + +def test_entity_id_no_dot() -> None: + with pytest.raises(Invalid, match="exactly one dot"): + cv.entity_id("nodot") + + +def test_entity_id_invalid_char() -> None: + with pytest.raises(Invalid, match="Invalid character"): + cv.entity_id("light.living!room") + + +# --------------------------------------------------------------------------- +# extract_keys / typed_schema +# --------------------------------------------------------------------------- + + +def test_extract_keys_from_schema() -> None: + schema = cv.Schema({cv.Optional("b"): cv.int_, cv.Required("a"): cv.int_}) + assert cv.extract_keys(schema) == ["a", "b"] + + +def test_extract_keys_from_dict() -> None: + assert cv.extract_keys({"x": cv.int_, cv.Optional("y"): cv.int_}) == ["x", "y"] + + +def test_extract_keys_invalid_key() -> None: + with pytest.raises(ValueError): + cv.extract_keys({1: cv.int_}) + + +def test_typed_schema_basic() -> None: + schema = cv.typed_schema({"foo": cv.Schema({cv.Optional("x"): cv.int_})}) + assert schema({"type": "foo", "x": 5}) == {"type": "foo", "x": 5} + + +def test_typed_schema_not_dict() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="must be dict"): + schema("notdict") + + +def test_typed_schema_missing_key() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="type not specified"): + schema({"x": 5}) + + +def test_typed_schema_default_type() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, default_type="foo") + assert schema({}) == {"type": "foo"} + + +def test_typed_schema_with_enum() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, enum={"foo": 42}) + result = schema({"type": "foo"}) + assert result["type"] == "foo" + assert result["type"].enum_value == 42 + + +# --------------------------------------------------------------------------- +# SplitDefault / OnlyWithout +# --------------------------------------------------------------------------- + + +def test_split_default_no_match() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + schema = cv.Schema({cv.SplitDefault("key", esp32="value"): cv.string}) + assert "key" not in schema({}) + + +def test_only_without_component_absent() -> None: + CORE.loaded_integrations = set() + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert schema({})["key"] == "dval" + + +def test_only_without_component_present() -> None: + CORE.loaded_integrations = {"mqtt"} + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert "key" not in schema({}) + + +# --------------------------------------------------------------------------- +# _entity_base_validator / ensure_schema +# --------------------------------------------------------------------------- + + +def test_entity_base_validator_name_present() -> None: + result = cv._entity_base_validator({CONF_NAME: "My Name"}) + assert result[CONF_NAME] == "My Name" + + +def test_entity_base_validator_neither() -> None: + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator({}) + + +def test_entity_base_validator_id_not_manual() -> None: + config = {CONF_ID: ID("auto", is_declaration=True, type=int, is_manual=False)} + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator(config) + + +def test_entity_base_validator_id_manual() -> None: + config = {CONF_ID: ID("myid", is_declaration=True, type=int, is_manual=True)} + result = cv._entity_base_validator(config) + assert result[CONF_NAME] == "myid" + assert result[CONF_INTERNAL] is True + + +def test_entity_base_validator_name_none() -> None: + result = cv._entity_base_validator({CONF_NAME: None}) + assert result[CONF_NAME] == "" + + +def test_ensure_schema_passthrough() -> None: + schema = cv.Schema({}) + assert cv.ensure_schema(schema) is schema + + +def test_ensure_schema_wraps() -> None: + result = cv.ensure_schema({cv.Optional("x"): cv.int_}) + assert isinstance(result, cv.Schema) + + +# --------------------------------------------------------------------------- +# validate_registry_entry +# --------------------------------------------------------------------------- + + +def _make_registry(*names: str, type_id: object = int) -> Registry: + registry = Registry() + for name in names: + registry.register(name, type_id, cv.Schema({cv.Optional("param"): cv.int_}))( + lambda: None + ) + return registry + + +def test_validate_registry_entry_string_shorthand() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)("foo") + assert "foo" in result + + +def test_validate_registry_entry_not_mapping() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="must consist of key-value mapping"): + cv.validate_registry_entry("action", registry)(5) + + +def test_validate_registry_entry_missing_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Key missing"): + cv.validate_registry_entry("action", registry)({}) + + +def test_validate_registry_entry_unknown_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Unable to find action"): + cv.validate_registry_entry("action", registry)({"unknown": {}}) + + +def test_validate_registry_entry_two_keys() -> None: + registry = _make_registry("foo", "bar") + with pytest.raises(Invalid, match="Cannot have two action"): + cv.validate_registry_entry("action", registry)({"foo": {}, "bar": {}}) + + +def test_validate_registry_entry_none_value() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)({"foo": None}) + assert "foo" in result + + +def test_validate_registry_entry_no_type_id() -> None: + registry = _make_registry("foo", type_id=None) + result = cv.validate_registry_entry("action", registry)({"foo": {}}) + assert "foo" in result + + +# --------------------------------------------------------------------------- +# maybe_simple_value / entity_category +# --------------------------------------------------------------------------- + + +def test_maybe_simple_value_schema_extract() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + validator, key = cv.maybe_simple_value(schema)(SCHEMA_EXTRACT) + assert key == CONF_VALUE + + +def test_maybe_simple_value_dict_with_key() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)({"value": "x"}) == {"value": "x"} + + +def test_maybe_simple_value_plain() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)("x") == {"value": "x"} + + +def test_maybe_simple_value_custom_key() -> None: + schema = cv.Schema({cv.Required("name"): cv.string}) + assert cv.maybe_simple_value(schema, key="name")({"name": "x"}) == {"name": "x"} + + +def test_entity_category_valid() -> None: + assert cv.entity_category("config") == "config" + + +def test_entity_category_invalid() -> None: + with pytest.raises(Invalid): + cv.entity_category("bogus") + + +# --------------------------------------------------------------------------- +# url / git_ref / source_refresh / version helpers +# --------------------------------------------------------------------------- + + +def test_url_valid() -> None: + assert cv.url("https://example.com/path") == "https://example.com/path" + + +def test_url_file_scheme() -> None: + assert cv.url("file:///tmp/x") == "file:///tmp/x" + + +def test_url_invalid_value_error() -> None: + with pytest.raises(Invalid, match="Not a valid URL"): + cv.url("http://[::1") + + +def test_url_no_host() -> None: + with pytest.raises(Invalid, match="Expected a file scheme"): + cv.url("notaurl") + + +def test_git_ref_valid() -> None: + assert cv.git_ref("v1.2.3") == "v1.2.3" + + +def test_git_ref_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid git ref"): + cv.git_ref("!!!") + + +def test_source_refresh_always() -> None: + assert cv.source_refresh("always").total_seconds == 0 + + +def test_source_refresh_never() -> None: + assert cv.source_refresh("never").total_seconds == 365250 * 24 * 3600 + + +def test_source_refresh_value() -> None: + assert cv.source_refresh("60s").total_seconds == 60 + + +def test_version_number_valid() -> None: + assert cv.version_number("2024.5.1") == "2024.5.1" + + +def test_version_number_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid version number"): + cv.version_number("notaversion") + + +def test_validate_esphome_version_ok() -> None: + assert cv.validate_esphome_version("1.0.0") == "1.0.0" + + +def test_validate_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="ESPHome version is too old"): + cv.validate_esphome_version("9999.0.0") + + +def test_platformio_version_constraint_no_op() -> None: + assert cv.platformio_version_constraint("1.2.3") == [(None, "1.2.3")] + + +def test_platformio_version_constraint_with_ops() -> None: + assert cv.platformio_version_constraint(">=1.2.3,<2.0.0") == [ + (">=", "1.2.3"), + ("<", "2.0.0"), + ] + + +# --------------------------------------------------------------------------- +# require_framework_version (no extra_message) / require_esphome_version +# --------------------------------------------------------------------------- + + +def test_require_framework_version_incompatible_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="incompatible with ESP32"): + cv.require_framework_version()("test") + + +def test_require_framework_version_too_low_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="at least framework version 2.0.0"): + cv.require_framework_version(esp32_arduino=cv.Version(2, 0, 0))("test") + + +def test_require_framework_version_too_high_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(2, 0, 0)) + with pytest.raises(Invalid, match="version 1.0.0 or lower"): + cv.require_framework_version( + esp32_arduino=cv.Version(1, 0, 0), max_version=True + )("test") + + +def test_require_esphome_version_ok() -> None: + assert cv.require_esphome_version(1, 0, 0)("test") == "test" + + +def test_require_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(9999, 0, 0)("test") + + +# --------------------------------------------------------------------------- +# suppress_invalid / validate_source_shorthand / rename_key +# --------------------------------------------------------------------------- + + +def test_suppress_invalid() -> None: + with cv.suppress_invalid(): + raise Invalid("suppressed") + + +def test_validate_source_shorthand_not_string() -> None: + with pytest.raises(Invalid, match="Shorthand only for strings"): + cv.validate_source_shorthand(123) + + +def test_validate_source_shorthand_local_path(setup_core: Path) -> None: + (setup_core / "mydir").mkdir() + result = cv.validate_source_shorthand("mydir") + assert result[CONF_TYPE] == TYPE_LOCAL + + +def test_validate_source_shorthand_github(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo@main") + assert result[CONF_TYPE] == TYPE_GIT + assert result[CONF_REF] == "main" + + +def test_validate_source_shorthand_github_no_ref(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo") + assert result[CONF_TYPE] == TYPE_GIT + assert CONF_REF not in result + + +def test_validate_source_shorthand_github_pr(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://pr#1234") + assert result[CONF_REF] == "pull/1234/head" + + +def test_validate_source_shorthand_invalid(setup_core: Path) -> None: + with pytest.raises(Invalid, match="not a file system path"): + cv.validate_source_shorthand("notvalid") + + +def test_rename_key_present() -> None: + assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5} + + +def test_rename_key_absent() -> None: + assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} From 0fcf512148ac2e948e2e03d43738fa139923099b Mon Sep 17 00:00:00 2001 From: Tomasz Witke Date: Thu, 25 Jun 2026 13:03:50 +0200 Subject: [PATCH 159/343] [image] Use LVGL 9 color formats (#16871) --- esphome/components/image/image.cpp | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index c95b693cf0b..9b603683abc 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -123,26 +123,18 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { break; case IMAGE_TYPE_RGB: -#if LV_COLOR_DEPTH == 32 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA; + this->dsc_.header.cf = LV_COLOR_FORMAT_ARGB8888; break; case TRANSPARENCY_CHROMA_KEY: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED; - break; default: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR; + this->dsc_.header.cf = LV_COLOR_FORMAT_RGB888; break; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_COLOR_FORMAT_ARGB8888 : LV_COLOR_FORMAT_RGB888; -#endif break; case IMAGE_TYPE_RGB565: -#if LV_COLOR_DEPTH == 16 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565A8; @@ -150,10 +142,6 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { default: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565; -#endif break; } } From f769457bb0e37ef6163ee593058e640146653d5a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:30 +1200 Subject: [PATCH 160/343] Mark configurable classes as final (15/21: script-slow_pwm) (#16966) --- esphome/components/script/script.h | 8 ++++---- esphome/components/sdl/sdl_esphome.h | 2 +- esphome/components/sdl/touchscreen/sdl_touchscreen.h | 2 +- esphome/components/sdm_meter/sdm_meter.h | 2 +- esphome/components/sdp3x/sdp3x.h | 4 +++- esphome/components/sds011/sds011.h | 2 +- .../seeed_mr24hpc1/button/custom_mode_end_button.h | 2 +- .../seeed_mr24hpc1/button/restart_button.h | 2 +- .../seeed_mr24hpc1/number/custom_mode_number.h | 2 +- .../seeed_mr24hpc1/number/custom_unman_time_number.h | 2 +- .../number/existence_threshold_number.h | 2 +- .../seeed_mr24hpc1/number/motion_threshold_number.h | 2 +- .../number/motion_trigger_time_number.h | 2 +- .../seeed_mr24hpc1/number/motiontorest_time_number.h | 2 +- .../seeed_mr24hpc1/number/sensitivity_number.h | 2 +- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h | 4 ++-- .../select/existence_boundary_select.h | 2 +- .../seeed_mr24hpc1/select/motion_boundary_select.h | 2 +- .../seeed_mr24hpc1/select/scene_mode_select.h | 2 +- .../seeed_mr24hpc1/select/unman_time_select.h | 2 +- .../seeed_mr24hpc1/switch/underlyFuc_switch.h | 2 +- esphome/components/seeed_mr60bha2/seeed_mr60bha2.h | 4 ++-- .../button/get_radar_parameters_button.h | 2 +- .../seeed_mr60fda2/button/reset_radar_button.h | 2 +- esphome/components/seeed_mr60fda2/seeed_mr60fda2.h | 4 ++-- .../seeed_mr60fda2/select/height_threshold_select.h | 2 +- .../seeed_mr60fda2/select/install_height_select.h | 2 +- .../seeed_mr60fda2/select/sensitivity_select.h | 2 +- esphome/components/selec_meter/selec_meter.h | 2 +- esphome/components/select/automation.h | 12 ++++++------ esphome/components/sen0321/sen0321.h | 2 +- esphome/components/sen21231/sen21231.h | 2 +- esphome/components/sen5x/automation.h | 2 +- esphome/components/sen5x/sen5x.h | 2 +- esphome/components/sen6x/sen6x.h | 2 +- esphome/components/sendspin/automation.h | 2 +- .../sendspin/media_player/sendspin_media_player.h | 2 +- .../components/sendspin/media_source/automations.h | 4 ++-- .../sendspin/media_source/sendspin_media_source.h | 6 +++--- esphome/components/sendspin/sensor/sendspin_sensor.h | 4 ++-- .../sendspin/text_sensor/sendspin_text_sensor.h | 2 +- esphome/components/senseair/senseair.h | 12 ++++++------ esphome/components/sensor/automation.h | 10 +++++----- esphome/components/serial_proxy/serial_proxy.h | 2 +- esphome/components/servo/servo.h | 6 +++--- esphome/components/sfa30/sfa30.h | 2 +- esphome/components/sgp30/sgp30.h | 2 +- esphome/components/sgp4x/sgp4x.h | 4 +++- esphome/components/shelly_dimmer/shelly_dimmer.h | 2 +- esphome/components/sht3xd/sht3xd.h | 2 +- esphome/components/sht4x/sht4x.h | 2 +- esphome/components/shtcx/shtcx.h | 2 +- esphome/components/shutdown/button/shutdown_button.h | 2 +- esphome/components/shutdown/switch/shutdown_switch.h | 2 +- .../sigma_delta_output/sigma_delta_output.h | 2 +- esphome/components/sim800l/sim800l.h | 12 ++++++------ esphome/components/slow_pwm/slow_pwm_output.h | 2 +- 57 files changed, 92 insertions(+), 88 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 6cd33e566cc..790ac107c5b 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -216,7 +216,7 @@ template class ParallelScript : public Script { template class ScriptExecuteAction; -template class ScriptExecuteAction, Ts...> : public Action { +template class ScriptExecuteAction, Ts...> final : public Action { public: ScriptExecuteAction(Script *script) : script_(script) {} @@ -254,7 +254,7 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction : public Action { +template class ScriptStopAction final : public Action { public: ScriptStopAction(C *script) : script_(script) {} @@ -264,7 +264,7 @@ template class ScriptStopAction : public Action C *script_; }; -template class IsRunningCondition : public Condition { +template class IsRunningCondition final : public Condition { public: explicit IsRunningCondition(C *parent) : parent_(parent) {} @@ -281,7 +281,7 @@ template class IsRunningCondition : public Condition class ScriptWaitAction : public Action, public Component { +template class ScriptWaitAction final : public Action, public Component { public: ScriptWaitAction(C *script) : script_(script) {} diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index a5ebf44c38b..635eb1e3f81 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { constexpr static const char *const TAG = "sdl"; -class Sdl : public display::Display { +class Sdl final : public display::Display { public: display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } void update() override; diff --git a/esphome/components/sdl/touchscreen/sdl_touchscreen.h b/esphome/components/sdl/touchscreen/sdl_touchscreen.h index cf2fd650889..50a584949be 100644 --- a/esphome/components/sdl/touchscreen/sdl_touchscreen.h +++ b/esphome/components/sdl/touchscreen/sdl_touchscreen.h @@ -6,7 +6,7 @@ namespace esphome::sdl { -class SdlTouchscreen : public touchscreen::Touchscreen, public Parented { +class SdlTouchscreen final : public touchscreen::Touchscreen, public Parented { public: void setup() override { this->x_raw_max_ = this->display_->get_width(); diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index e729e29d6c0..a4dbde016c5 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdp3x/sdp3x.h b/esphome/components/sdp3x/sdp3x.h index c4ef6a4a1e8..19c8d0f6787 100644 --- a/esphome/components/sdp3x/sdp3x.h +++ b/esphome/components/sdp3x/sdp3x.h @@ -8,7 +8,9 @@ namespace esphome::sdp3x { enum MeasurementMode { MASS_FLOW_AVG, DP_AVG }; -class SDP3XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice, public sensor::Sensor { +class SDP3XComponent final : public PollingComponent, + public sensirion_common::SensirionI2CDevice, + public sensor::Sensor { public: /// Schedule temperature+pressure readings. void update() override; diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 56d46d118f8..4f4571ab693 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -7,7 +7,7 @@ namespace esphome::sds011 { -class SDS011Component : public Component, public uart::UARTDevice { +class SDS011Component final : public Component, public uart::UARTDevice { public: SDS011Component() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h index bc98bb93b68..fc0cbbdc769 100644 --- a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h +++ b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomSetEndButton : public button::Button, public Parented { +class CustomSetEndButton final : public button::Button, public Parented { public: CustomSetEndButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/restart_button.h b/esphome/components/seeed_mr24hpc1/button/restart_button.h index 49a4f46138e..c6c530004b3 100644 --- a/esphome/components/seeed_mr24hpc1/button/restart_button.h +++ b/esphome/components/seeed_mr24hpc1/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h index f51e592fc00..842530a3791 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomModeNumber : public number::Number, public Parented { +class CustomModeNumber final : public number::Number, public Parented { public: CustomModeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h index 281e727a365..0ef20731956 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomUnmanTimeNumber : public number::Number, public Parented { +class CustomUnmanTimeNumber final : public number::Number, public Parented { public: CustomUnmanTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h index c811b2d6b69..11aa45a6dc5 100644 --- a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceThresholdNumber : public number::Number, public Parented { +class ExistenceThresholdNumber final : public number::Number, public Parented { public: ExistenceThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h index 748119f1985..01f62f67fb0 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionThresholdNumber : public number::Number, public Parented { +class MotionThresholdNumber final : public number::Number, public Parented { public: MotionThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h index dd7947b2a5a..44cf89837e3 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionTriggerTimeNumber : public number::Number, public Parented { +class MotionTriggerTimeNumber final : public number::Number, public Parented { public: MotionTriggerTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h index 47493e79542..c12f14e79f1 100644 --- a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionToRestTimeNumber : public number::Number, public Parented { +class MotionToRestTimeNumber final : public number::Number, public Parented { public: MotionToRestTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h index c1d54351517..954c004e671 100644 --- a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h +++ b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SensitivityNumber : public number::Number, public Parented { +class SensitivityNumber final : public number::Number, public Parented { public: SensitivityNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b62504ba0eb..b231bab33e5 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -92,8 +92,8 @@ static const char *const S_BOUNDARY_STR[10] = {"0.5m", "1.0m", "1.5m", "2.0m", " "3.0m", "3.5m", "4.0m", "4.5m", "5.0m"}; // uint: m static const float S_PRESENCE_OF_DETECTION_RANGE_STR[7] = {0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f}; // uint: m -class MR24HPC1Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR24HPC1Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_TEXT_SENSOR SUB_TEXT_SENSOR(heartbeat_state) SUB_TEXT_SENSOR(product_model) diff --git a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h index 878d0525c90..1fce716ed6c 100644 --- a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceBoundarySelect : public select::Select, public Parented { +class ExistenceBoundarySelect final : public select::Select, public Parented { public: ExistenceBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h index eecdef2019f..721bc67f69f 100644 --- a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionBoundarySelect : public select::Select, public Parented { +class MotionBoundarySelect final : public select::Select, public Parented { public: MotionBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h index 377c61b32f4..40e365aa7b9 100644 --- a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h +++ b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SceneModeSelect : public select::Select, public Parented { +class SceneModeSelect final : public select::Select, public Parented { public: SceneModeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h index e68ae5e54fd..bba53635655 100644 --- a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h +++ b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnmanTimeSelect : public select::Select, public Parented { +class UnmanTimeSelect final : public select::Select, public Parented { public: UnmanTimeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h index 3224640ce7b..8b8dbdf5de5 100644 --- a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h +++ b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnderlyOpenFunctionSwitch : public switch_::Switch, public Parented { +class UnderlyOpenFunctionSwitch final : public switch_::Switch, public Parented { public: UnderlyOpenFunctionSwitch() = default; diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h index 008acc6a57d..0ce25790ccd 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h @@ -21,8 +21,8 @@ static const uint16_t HEART_RATE_TYPE_BUFFER = 0x0A15; static const uint16_t DISTANCE_TYPE_BUFFER = 0x0A16; static const uint16_t PRINT_CLOUD_BUFFER = 0x0A04; -class MR60BHA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60BHA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(has_target); #endif diff --git a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h index c1b96d5f083..7a604592c0d 100644 --- a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h +++ b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class GetRadarParametersButton : public button::Button, public Parented { +class GetRadarParametersButton final : public button::Button, public Parented { public: GetRadarParametersButton() = default; diff --git a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h index 174ef5425e5..cdfb2599092 100644 --- a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h +++ b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class ResetRadarButton : public button::Button, public Parented { +class ResetRadarButton final : public button::Button, public Parented { public: ResetRadarButton() = default; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h index 0e97447074f..f231de5eec5 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h @@ -56,8 +56,8 @@ static const char *const INSTALL_HEIGHT_STR[7] = {"2.4m", "2.5m", "2.6", "2.7m", static const char *const HEIGHT_THRESHOLD_STR[7] = {"0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"}; static const char *const SENSITIVITY_STR[3] = {"1", "2", "3"}; -class MR60FDA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60FDA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(people_exist) SUB_BINARY_SENSOR(fall_detected) diff --git a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h index 0e495766588..0c930853371 100644 --- a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h +++ b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class HeightThresholdSelect : public select::Select, public Parented { +class HeightThresholdSelect final : public select::Select, public Parented { public: HeightThresholdSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/install_height_select.h b/esphome/components/seeed_mr60fda2/select/install_height_select.h index c1e2a3eeb1e..964edfa127b 100644 --- a/esphome/components/seeed_mr60fda2/select/install_height_select.h +++ b/esphome/components/seeed_mr60fda2/select/install_height_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class InstallHeightSelect : public select::Select, public Parented { +class InstallHeightSelect final : public select::Select, public Parented { public: InstallHeightSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h index f2e0307dc10..1d96257871f 100644 --- a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h +++ b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class SensitivitySelect : public select::Select, public Parented { +class SensitivitySelect final : public select::Select, public Parented { public: SensitivitySelect() = default; diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 159acab124f..6b5552a0981 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index ffdabd5f7c1..8e5da893ad4 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,7 +6,7 @@ namespace esphome::select { -class SelectStateTrigger : public Trigger { +class SelectStateTrigger final : public Trigger { public: explicit SelectStateTrigger(Select *parent) : parent_(parent) { parent->add_on_state_callback( @@ -17,7 +17,7 @@ class SelectStateTrigger : public Trigger { Select *parent_; }; -template class SelectSetAction : public Action { +template class SelectSetAction final : public Action { public: explicit SelectSetAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(std::string, option) @@ -32,7 +32,7 @@ template class SelectSetAction : public Action { Select *select_; }; -template class SelectSetIndexAction : public Action { +template class SelectSetIndexAction final : public Action { public: explicit SelectSetIndexAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(size_t, index) @@ -47,7 +47,7 @@ template class SelectSetIndexAction : public Action { Select *select_; }; -template class SelectOperationAction : public Action { +template class SelectOperationAction final : public Action { public: explicit SelectOperationAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(bool, cycle) @@ -66,7 +66,7 @@ template class SelectOperationAction : public Action { Select *select_; }; -template class SelectIsCondition : public Condition { +template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} @@ -85,7 +85,7 @@ template class SelectIsCondition : public Condition class SelectIsCondition<0, Ts...> : public Condition { +template class SelectIsCondition<0, Ts...> final : public Condition { public: SelectIsCondition(Select *parent, std::function &&f) : parent_(parent), f_(f) {} diff --git a/esphome/components/sen0321/sen0321.h b/esphome/components/sen0321/sen0321.h index 6d5aa20a610..ed7df3fcafb 100644 --- a/esphome/components/sen0321/sen0321.h +++ b/esphome/components/sen0321/sen0321.h @@ -20,7 +20,7 @@ static const uint8_t SET_REGISTER = 0x04; static const uint8_t SENSOR_PASS_READ_REG = 0x07; static const uint8_t SENSOR_AUTO_READ_REG = 0x09; -class Sen0321Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen0321Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen21231/sen21231.h b/esphome/components/sen21231/sen21231.h index 486a9473d2d..ad059660116 100644 --- a/esphome/components/sen21231/sen21231.h +++ b/esphome/components/sen21231/sen21231.h @@ -63,7 +63,7 @@ using person_sensor_results_t = struct __attribute__((__packed__)) { uint16_t checksum; // Bytes 38-39. }; -class Sen21231Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen21231Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h index e6111f4a8fa..21d938c4fea 100644 --- a/esphome/components/sen5x/automation.h +++ b/esphome/components/sen5x/automation.h @@ -6,7 +6,7 @@ namespace esphome::sen5x { -template class StartFanAction : public Action { +template class StartFanAction final : public Action { public: explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index ec8f9cc5447..6b5a1f85103 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -44,7 +44,7 @@ struct TemperatureCompensation { // Prevents wear of the flash because of too many write operations static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 2 * 60 * 60 * 1000; -class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN5XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index bc44611882f..041bf3b1aa3 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -6,7 +6,7 @@ namespace esphome::sen6x { -class SEN6XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h index be3b1eb39d3..0b408b12350 100644 --- a/esphome/components/sendspin/automation.h +++ b/esphome/components/sendspin/automation.h @@ -10,7 +10,7 @@ namespace esphome::sendspin_ { #ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction : public Action, public Parented { +template class SendspinSwitchCommandAction final : public Action, public Parented { public: void play(const Ts &...x) override { // Clear any EXTERNAL_SOURCE state so the switch command is followed diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 52786d6d7b3..651e1562bed 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -9,7 +9,7 @@ namespace esphome::sendspin_ { -class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { +class SendspinMediaPlayer final : public SendspinChild, public media_player::MediaPlayer { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h index 08d2b2004b1..f5c35f107af 100644 --- a/esphome/components/sendspin/media_source/automations.h +++ b/esphome/components/sendspin/media_source/automations.h @@ -10,13 +10,13 @@ namespace esphome::sendspin_ { template -class EnableStaticDelayAdjustmentAction : public Action, public Parented { +class EnableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } }; template -class DisableStaticDelayAdjustmentAction : public Action, public Parented { +class DisableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } }; diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 843578783e0..1c5cb625bf4 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -17,9 +17,9 @@ namespace esphome::sendspin_ { /// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's /// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards /// transport commands to the hub's controller role. -class SendspinMediaSource : public SendspinChild, - public media_source::MediaSource, - public sendspin::PlayerRoleListener { +class SendspinMediaSource final : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index cbfe1742c95..5b29fff55f8 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -11,7 +11,7 @@ namespace esphome::sendspin_ { -class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { +class SendspinTrackProgressSensor final : public sensor::Sensor, public SendspinPollingChild { public: void dump_config() override; void setup() override; @@ -24,7 +24,7 @@ enum class SendspinNumericMetadataTypes { TRACK, }; -class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { +class SendspinMetadataSensor final : public sensor::Sensor, public SendspinChild { public: void dump_config() override; void setup() override; diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 203b01d0248..d38f360d94d 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -18,7 +18,7 @@ enum class SendspinTextMetadataTypes { ALBUM_ARTIST, }; -class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { +class SendspinTextSensor final : public SendspinChild, public text_sensor::TextSensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 333c003f48d..48154a53d95 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -18,7 +18,7 @@ enum SenseAirStatus : uint8_t { RESERVED = 1 << 7 }; -class SenseAirComponent : public PollingComponent, public uart::UARTDevice { +class SenseAirComponent final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } @@ -37,7 +37,7 @@ class SenseAirComponent : public PollingComponent, public uart::UARTDevice { sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction : public Action { +template class SenseAirBackgroundCalibrationAction final : public Action { public: SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -47,7 +47,7 @@ template class SenseAirBackgroundCalibrationAction : public Acti SenseAirComponent *senseair_; }; -template class SenseAirBackgroundCalibrationResultAction : public Action { +template class SenseAirBackgroundCalibrationResultAction final : public Action { public: SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -57,7 +57,7 @@ template class SenseAirBackgroundCalibrationResultAction : publi SenseAirComponent *senseair_; }; -template class SenseAirABCEnableAction : public Action { +template class SenseAirABCEnableAction final : public Action { public: SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -67,7 +67,7 @@ template class SenseAirABCEnableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCDisableAction : public Action { +template class SenseAirABCDisableAction final : public Action { public: SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -77,7 +77,7 @@ template class SenseAirABCDisableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCGetPeriodAction : public Action { +template class SenseAirABCGetPeriodAction final : public Action { public: SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 37578f5320d..35a4a29e0d2 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,21 +6,21 @@ namespace esphome::sensor { -class SensorStateTrigger : public Trigger { +class SensorStateTrigger final : public Trigger { public: explicit SensorStateTrigger(Sensor *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -class SensorRawStateTrigger : public Trigger { +class SensorRawStateTrigger final : public Trigger { public: explicit SensorRawStateTrigger(Sensor *parent) { parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); } }; -template class SensorPublishAction : public Action { +template class SensorPublishAction final : public Action { public: SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(float, state) @@ -31,7 +31,7 @@ template class SensorPublishAction : public Action { Sensor *sensor_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} @@ -83,7 +83,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class SensorInRangeCondition : public Condition { +template class SensorInRangeCondition final : public Condition { public: SensorInRangeCondition(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index c435787a61e..e35fab3d421 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -41,7 +41,7 @@ enum SerialProxyLineStateFlag : uint32_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; -class SerialProxy : public uart::UARTDevice, public Component { +class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 31e93579471..156dab6dc1e 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -10,7 +10,7 @@ namespace esphome::servo { extern uint32_t global_servo_id; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class Servo : public Component { +class Servo final : public Component { public: void set_output(output::FloatOutput *output) { output_ = output; } void loop() override; @@ -51,7 +51,7 @@ class Servo : public Component { }; }; -template class ServoWriteAction : public Action { +template class ServoWriteAction final : public Action { public: ServoWriteAction(Servo *servo) : servo_(servo) {} TEMPLATABLE_VALUE(float, value) @@ -62,7 +62,7 @@ template class ServoWriteAction : public Action { Servo *servo_; }; -template class ServoDetachAction : public Action { +template class ServoDetachAction final : public Action { public: ServoDetachAction(Servo *servo) : servo_(servo) {} diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index d2f2520a576..13985b1a294 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -6,7 +6,7 @@ namespace esphome::sfa30 { -class SFA30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SFA30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; public: diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index cb4aa1c1bb1..fac3c01b58f 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -16,7 +16,7 @@ struct SGP30Baselines { } PACKED; /// This class implements support for the Sensirion SGP30 i2c GAS (VOC and CO2eq) sensors. -class SGP30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SGP30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_eco2_sensor(sensor::Sensor *eco2) { eco2_sensor_ = eco2; } void set_tvoc_sensor(sensor::Sensor *tvoc) { tvoc_sensor_ = tvoc; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 23bf6319a90..a40188e6293 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -54,7 +54,9 @@ const float MAXIMUM_STORAGE_DIFF = 50.0f; class SGP4xComponent; /// This class implements support for the Sensirion sgp4x i2c GAS (VOC) sensors. -class SGP4xComponent : public PollingComponent, public sensor::Sensor, public sensirion_common::SensirionI2CDevice { +class SGP4xComponent final : public PollingComponent, + public sensor::Sensor, + public sensirion_common::SensirionI2CDevice { enum ErrorCode { COMMUNICATION_FAILED, MEASUREMENT_INIT_FAILED, diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.h b/esphome/components/shelly_dimmer/shelly_dimmer.h index c6d0e20afe8..e3ddd7f2681 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.h +++ b/esphome/components/shelly_dimmer/shelly_dimmer.h @@ -12,7 +12,7 @@ namespace esphome::shelly_dimmer { -class ShellyDimmer : public PollingComponent, public light::LightOutput, public uart::UARTDevice { +class ShellyDimmer final : public PollingComponent, public light::LightOutput, public uart::UARTDevice { private: static constexpr uint16_t SHELLY_DIMMER_BUFFER_SIZE = 256; diff --git a/esphome/components/sht3xd/sht3xd.h b/esphome/components/sht3xd/sht3xd.h index 6df5587507c..93663118e59 100644 --- a/esphome/components/sht3xd/sht3xd.h +++ b/esphome/components/sht3xd/sht3xd.h @@ -7,7 +7,7 @@ namespace esphome::sht3xd { /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHT3XDComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT3XDComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index d1fa9033df4..0d5723f72a2 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -14,7 +14,7 @@ enum SHT4XHEATERPOWER { SHT4X_HEATERPOWER_HIGH, SHT4X_HEATERPOWER_MED, SHT4X_HEA enum SHT4XHEATERTIME : uint16_t { SHT4X_HEATERTIME_LONG = 1100, SHT4X_HEATERTIME_SHORT = 110 }; -class SHT4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/shtcx/shtcx.h b/esphome/components/shtcx/shtcx.h index a86b204e2b7..ea50a084ef5 100644 --- a/esphome/components/shtcx/shtcx.h +++ b/esphome/components/shtcx/shtcx.h @@ -13,7 +13,7 @@ enum SHTCXType : uint8_t { }; /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHTCXComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHTCXComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/shutdown/button/shutdown_button.h b/esphome/components/shutdown/button/shutdown_button.h index d4247ec0f98..4fc534030e0 100644 --- a/esphome/components/shutdown/button/shutdown_button.h +++ b/esphome/components/shutdown/button/shutdown_button.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownButton : public button::Button, public Component { +class ShutdownButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/shutdown/switch/shutdown_switch.h b/esphome/components/shutdown/switch/shutdown_switch.h index 933345915ff..bb7fea7e03f 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.h +++ b/esphome/components/shutdown/switch/shutdown_switch.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownSwitch : public switch_::Switch, public Component { +class ShutdownSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.h b/esphome/components/sigma_delta_output/sigma_delta_output.h index a5df3c6c7c2..71aedf9b074 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.h +++ b/esphome/components/sigma_delta_output/sigma_delta_output.h @@ -6,7 +6,7 @@ namespace esphome::sigma_delta_output { -class SigmaDeltaOutput : public PollingComponent, public output::FloatOutput { +class SigmaDeltaOutput final : public PollingComponent, public output::FloatOutput { public: Trigger<> *get_turn_on_trigger() { if (!this->turn_on_trigger_) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 0b3259ede00..276131cfed8 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -46,7 +46,7 @@ enum State { STATE_RECEIVED_USSD }; -class Sim800LComponent : public uart::UARTDevice, public PollingComponent { +class Sim800LComponent final : public uart::UARTDevice, public PollingComponent { public: /// Retrieve the latest sensor values. This operation takes approximately 16ms. void update() override; @@ -120,7 +120,7 @@ class Sim800LComponent : public uart::UARTDevice, public PollingComponent { CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction : public Action { +template class Sim800LSendSmsAction final : public Action { public: Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -136,7 +136,7 @@ template class Sim800LSendSmsAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LSendUssdAction : public Action { +template class Sim800LSendUssdAction final : public Action { public: Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, ussd) @@ -150,7 +150,7 @@ template class Sim800LSendUssdAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDialAction : public Action { +template class Sim800LDialAction final : public Action { public: Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -163,7 +163,7 @@ template class Sim800LDialAction : public Action { protected: Sim800LComponent *parent_; }; -template class Sim800LConnectAction : public Action { +template class Sim800LConnectAction final : public Action { public: Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} @@ -173,7 +173,7 @@ template class Sim800LConnectAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDisconnectAction : public Action { +template class Sim800LDisconnectAction final : public Action { public: Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} diff --git a/esphome/components/slow_pwm/slow_pwm_output.h b/esphome/components/slow_pwm/slow_pwm_output.h index d866435af1e..aa517a3bc56 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.h +++ b/esphome/components/slow_pwm/slow_pwm_output.h @@ -6,7 +6,7 @@ namespace esphome::slow_pwm { -class SlowPWMOutput : public output::FloatOutput, public Component { +class SlowPWMOutput final : public output::FloatOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; }; void set_period(unsigned int period) { period_ = period; }; From eb9ca517e32d09e68979080bafe959c33af98aa0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:39 +1200 Subject: [PATCH 161/343] Mark configurable classes as final (14/21: rc522_i2c-scd4x) (#16965) --- esphome/components/rc522_i2c/rc522_i2c.h | 2 +- esphome/components/rc522_spi/rc522_spi.h | 6 +++--- esphome/components/rd03d/rd03d.h | 2 +- esphome/components/rdm6300/rdm6300.h | 6 +++--- esphome/components/remote_base/raw_protocol.h | 2 +- esphome/components/remote_base/remote_base.h | 2 +- .../remote_receiver/remote_receiver.h | 6 +++--- .../components/remote_transmitter/automation.h | 3 ++- .../remote_transmitter/remote_transmitter.h | 6 +++--- .../resampler/speaker/resampler_speaker.h | 2 +- .../components/resistance/resistance_sensor.h | 2 +- .../components/restart/switch/restart_switch.h | 2 +- esphome/components/rf_bridge/rf_bridge.h | 18 +++++++++--------- esphome/components/rgb/rgb_light_output.h | 2 +- esphome/components/rgbct/rgbct_light_output.h | 2 +- esphome/components/rgbw/rgbw_light_output.h | 2 +- esphome/components/rgbww/rgbww_light_output.h | 2 +- .../components/rotary_encoder/rotary_encoder.h | 4 ++-- .../components/router/speaker/router_speaker.h | 4 ++-- esphome/components/rp2040/gpio.h | 2 +- esphome/components/rp2040_ble/rp2040_ble.h | 2 +- .../rp2040_pio_led_strip/led_strip.h | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.h | 4 ++-- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 +- esphome/components/rtttl/rtttl.h | 8 ++++---- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/ruuvi_ble/ruuvi_ble.h | 2 +- esphome/components/ruuvitag/ruuvitag.h | 2 +- esphome/components/rx8130/rx8130.h | 6 +++--- esphome/components/safe_mode/automation.h | 2 +- .../safe_mode/switch/safe_mode_switch.h | 2 +- esphome/components/scd30/automation.h | 3 ++- esphome/components/scd30/scd30.h | 2 +- esphome/components/scd4x/automation.h | 5 +++-- esphome/components/scd4x/scd4x.h | 2 +- 35 files changed, 63 insertions(+), 60 deletions(-) diff --git a/esphome/components/rc522_i2c/rc522_i2c.h b/esphome/components/rc522_i2c/rc522_i2c.h index bd6f2269d8c..9144241fe7f 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.h +++ b/esphome/components/rc522_i2c/rc522_i2c.h @@ -6,7 +6,7 @@ namespace esphome::rc522_i2c { -class RC522I2C : public rc522::RC522, public i2c::I2CDevice { +class RC522I2C final : public rc522::RC522, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/rc522_spi/rc522_spi.h b/esphome/components/rc522_spi/rc522_spi.h index 54caf5c1171..2809718308b 100644 --- a/esphome/components/rc522_spi/rc522_spi.h +++ b/esphome/components/rc522_spi/rc522_spi.h @@ -14,9 +14,9 @@ */ namespace esphome::rc522_spi { -class RC522Spi : public rc522::RC522, - public spi::SPIDevice { +class RC522Spi final : public rc522::RC522, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/rd03d/rd03d.h b/esphome/components/rd03d/rd03d.h index 8bf7b423bed..e4ec6aafb21 100644 --- a/esphome/components/rd03d/rd03d.h +++ b/esphome/components/rd03d/rd03d.h @@ -37,7 +37,7 @@ struct TargetSensor { }; #endif -class RD03DComponent : public Component, public uart::UARTDevice { +class RD03DComponent final : public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/rdm6300/rdm6300.h b/esphome/components/rdm6300/rdm6300.h index f088f7de4c6..4aa31b8d60f 100644 --- a/esphome/components/rdm6300/rdm6300.h +++ b/esphome/components/rdm6300/rdm6300.h @@ -13,7 +13,7 @@ namespace esphome::rdm6300 { class RDM6300BinarySensor; class RDM6300Trigger; -class RDM6300Component : public Component, public uart::UARTDevice { +class RDM6300Component final : public Component, public uart::UARTDevice { public: void loop() override; @@ -28,7 +28,7 @@ class RDM6300Component : public Component, public uart::UARTDevice { uint32_t last_id_{0}; }; -class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { +class RDM6300BinarySensor final : public binary_sensor::BinarySensorInitiallyOff { public: void set_id(uint32_t id) { id_ = id; } @@ -46,7 +46,7 @@ class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { uint32_t id_; }; -class RDM6300Trigger : public Trigger { +class RDM6300Trigger final : public Trigger { public: void process(uint32_t uid) { this->trigger(uid); } }; diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 1bcf390b628..f043d95eb4e 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -31,7 +31,7 @@ class RawBinarySensor : public RemoteReceiverBinarySensorBase { size_t len_; }; -class RawTrigger : public Trigger, public Component, public RemoteReceiverListener { +class RawTrigger final : public Trigger, public Component, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { this->trigger(src.get_raw_data()); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 0b1109267fa..4e2ed4b71cb 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -256,7 +256,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin }; template -class RemoteReceiverTrigger : public Trigger, public RemoteReceiverListener { +class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index cc707346ebb..2ed6a4c251b 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -55,11 +55,11 @@ struct RemoteReceiverComponentStore { }; #endif -class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, - public Component +class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h index 8da4cfd95d0..a1b0926451c 100644 --- a/esphome/components/remote_transmitter/automation.h +++ b/esphome/components/remote_transmitter/automation.h @@ -7,7 +7,8 @@ namespace esphome::remote_transmitter { -template class DigitalWriteAction : public Action, public Parented { +template +class DigitalWriteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, value) void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index d30966e3daa..bcb07038ea9 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -33,11 +33,11 @@ struct RemoteTransmitterComponentStore { #endif #endif -class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, - public Component +class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { public: diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index f482ce4b883..3255bf1fe8c 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -14,7 +14,7 @@ namespace esphome::resampler { -class ResamplerSpeaker : public Component, public speaker::Speaker { +class ResamplerSpeaker final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index b646fb509a1..ecb77795ffe 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -10,7 +10,7 @@ enum ResistanceConfiguration { DOWNSTREAM, }; -class ResistanceSensor : public Component, public sensor::Sensor { +class ResistanceSensor final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_configuration(ResistanceConfiguration configuration) { configuration_ = configuration; } diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index 67b4a2bfd10..dc9ec8eadcd 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -5,7 +5,7 @@ namespace esphome::restart { -class RestartSwitch : public switch_::Switch, public Component { +class RestartSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 2f91459076f..5ad75650abb 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -44,7 +44,7 @@ struct RFBridgeAdvancedData { std::string code; }; -class RFBridgeComponent : public uart::UARTDevice, public Component { +class RFBridgeComponent final : public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; @@ -76,7 +76,7 @@ class RFBridgeComponent : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction : public Action { +template class RFBridgeSendCodeAction final : public Action { public: RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, sync) @@ -97,7 +97,7 @@ template class RFBridgeSendCodeAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeSendAdvancedCodeAction : public Action { +template class RFBridgeSendAdvancedCodeAction final : public Action { public: RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint8_t, length) @@ -116,7 +116,7 @@ template class RFBridgeSendAdvancedCodeAction : public Action class RFBridgeLearnAction : public Action { +template class RFBridgeLearnAction final : public Action { public: RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -126,7 +126,7 @@ template class RFBridgeLearnAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeStartAdvancedSniffingAction : public Action { +template class RFBridgeStartAdvancedSniffingAction final : public Action { public: RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -136,7 +136,7 @@ template class RFBridgeStartAdvancedSniffingAction : public Acti RFBridgeComponent *parent_; }; -template class RFBridgeStopAdvancedSniffingAction : public Action { +template class RFBridgeStopAdvancedSniffingAction final : public Action { public: RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -146,7 +146,7 @@ template class RFBridgeStopAdvancedSniffingAction : public Actio RFBridgeComponent *parent_; }; -template class RFBridgeStartBucketSniffingAction : public Action { +template class RFBridgeStartBucketSniffingAction final : public Action { public: RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -156,7 +156,7 @@ template class RFBridgeStartBucketSniffingAction : public Action RFBridgeComponent *parent_; }; -template class RFBridgeSendRawAction : public Action { +template class RFBridgeSendRawAction final : public Action { public: RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, raw) @@ -167,7 +167,7 @@ template class RFBridgeSendRawAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeBeepAction : public Action { +template class RFBridgeBeepAction final : public Action { public: RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, duration) diff --git a/esphome/components/rgb/rgb_light_output.h b/esphome/components/rgb/rgb_light_output.h index f0d599cf575..5893abf1d71 100644 --- a/esphome/components/rgb/rgb_light_output.h +++ b/esphome/components/rgb/rgb_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgb { -class RGBLightOutput : public light::LightOutput { +class RGBLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbct/rgbct_light_output.h b/esphome/components/rgbct/rgbct_light_output.h index 84ecb232cc6..d6f7aaef78d 100644 --- a/esphome/components/rgbct/rgbct_light_output.h +++ b/esphome/components/rgbct/rgbct_light_output.h @@ -7,7 +7,7 @@ namespace esphome::rgbct { -class RGBCTLightOutput : public light::LightOutput { +class RGBCTLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbw/rgbw_light_output.h b/esphome/components/rgbw/rgbw_light_output.h index ae96eb20246..a9571044570 100644 --- a/esphome/components/rgbw/rgbw_light_output.h +++ b/esphome/components/rgbw/rgbw_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbw { -class RGBWLightOutput : public light::LightOutput { +class RGBWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbww/rgbww_light_output.h b/esphome/components/rgbww/rgbww_light_output.h index de5ee993f8c..6608c65d9c7 100644 --- a/esphome/components/rgbww/rgbww_light_output.h +++ b/esphome/components/rgbww/rgbww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbww { -class RGBWWLightOutput : public light::LightOutput { +class RGBWWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 8a56da4fe27..286267baed9 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -41,7 +41,7 @@ struct RotaryEncoderSensorStore { static void gpio_intr(RotaryEncoderSensorStore *arg); }; -class RotaryEncoderSensor : public sensor::Sensor, public Component { +class RotaryEncoderSensor final : public sensor::Sensor, public Component { public: void set_pin_a(InternalGPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(InternalGPIOPin *pin_b) { pin_b_ = pin_b; } @@ -106,7 +106,7 @@ class RotaryEncoderSensor : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction : public Action { +template class RotaryEncoderSetValueAction final : public Action { public: RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} TEMPLATABLE_VALUE(int, value) diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 13b58a1c729..801d0906cee 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -13,7 +13,7 @@ namespace esphome::router { -class Router : public Component, public speaker::Speaker { +class Router final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return setup_priority::DATA; } @@ -77,7 +77,7 @@ class Router : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction : public Action { +template class SwitchOutputAction final : public Action { public: explicit SwitchOutputAction(Router *parent) : parent_(parent) {} TEMPLATABLE_VALUE(speaker::Speaker *, target) diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2040/gpio.h index da97cff9b1e..b9aa497b473 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2040/gpio.h @@ -7,7 +7,7 @@ namespace esphome::rp2040 { -class RP2040GPIOPin : public InternalGPIOPin { +class RP2040GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 24b3860cc1e..885e49f690c 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -18,7 +18,7 @@ enum class BLEComponentState : uint8_t { DISABLED, }; -class RP2040BLE : public Component { +class RP2040BLE final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index ebc3bbbaa54..aaa5b0842d6 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -57,7 +57,7 @@ inline const char *rgb_order_to_string(RGBOrder order) { using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); -class RP2040PIOLEDStripLightOutput : public light::AddressableLight { +class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 58d3955a31e..49980a7d766 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -9,7 +9,7 @@ namespace esphome::rp2040_pwm { -class RP2040PWM : public output::FloatOutput, public Component { +class RP2040PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -39,7 +39,7 @@ class RP2040PWM : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 8b1457926c1..df0e2b0b167 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -18,7 +18,7 @@ namespace esphome::rpi_dpi_rgb { constexpr static const char *const TAG = "rpi_dpi_rgb"; -class RpiDpiRgb : public display::Display { +class RpiDpiRgb final : public display::Display { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index d060b6b024d..256bdce5f21 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -27,7 +27,7 @@ enum class State : uint8_t { STOPPING, }; -class Rtttl : public Component { +class Rtttl final : public Component { public: #ifdef USE_OUTPUT void set_output(output::FloatOutput *output) { this->output_ = output; } @@ -116,7 +116,7 @@ class Rtttl : public Component { #endif }; -template class PlayAction : public Action { +template class PlayAction final : public Action { public: PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} TEMPLATABLE_VALUE(std::string, value) @@ -127,12 +127,12 @@ template class PlayAction : public Action { Rtttl *rtttl_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 1e4910453a9..2c783a0df30 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -17,7 +17,7 @@ namespace runtime_stats { static const char *const TAG = "runtime_stats"; -class RuntimeStatsCollector { +class RuntimeStatsCollector final { public: RuntimeStatsCollector(); diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index 80b07d410b0..e372b249444 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -25,7 +25,7 @@ bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); -class RuuviListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 259675835d5..9602b82afc9 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -9,7 +9,7 @@ namespace esphome::ruuvitag { -class RuuviTag : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 152bd10f273..0c738a9b78c 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -6,7 +6,7 @@ namespace esphome::rx8130 { -class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { +class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -18,12 +18,12 @@ class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { void stop_(bool stop); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index 79b53c08812..e2858dff341 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -4,7 +4,7 @@ namespace esphome::safe_mode { -template class MarkSuccessfulAction : public Action, public Parented { +template class MarkSuccessfulAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->mark_successful(); } }; diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index c73a2087d79..cbd79cd5202 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -6,7 +6,7 @@ namespace esphome::safe_mode { -class SafeModeSwitch : public switch_::Switch, public Component { +class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; void set_safe_mode(SafeModeComponent *safe_mode_component); diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h index 1f047398933..a816ae1f260 100644 --- a/esphome/components/scd30/automation.h +++ b/esphome/components/scd30/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd30 { -template class ForceRecalibrationWithReference : public Action, public Parented { +template +class ForceRecalibrationWithReference final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index a5a5df1903a..0605ab4175a 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -7,7 +7,7 @@ namespace esphome::scd30 { /// This class implements support for the Sensirion scd30 i2c GAS (VOC and CO2eq) sensors. -class SCD30Component : public Component, public sensirion_common::SensirionI2CDevice { +class SCD30Component final : public Component, public sensirion_common::SensirionI2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_humidity_sensor(sensor::Sensor *humidity) { humidity_sensor_ = humidity; } diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index e485289c95b..4746c0c879c 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd4x { -template class PerformForcedCalibrationAction : public Action, public Parented { +template +class PerformForcedCalibrationAction final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { @@ -18,7 +19,7 @@ template class PerformForcedCalibrationAction : public Action class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 3e4827ef141..4d5dedb5e94 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -22,7 +22,7 @@ enum MeasurementMode : uint8_t { SINGLE_SHOT_RHT_ONLY, }; -class SCD4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SCD4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; From d511f0614d1c151313928b819c06b0f4d4e643da Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:51 +1200 Subject: [PATCH 162/343] Mark configurable classes as final (18/21: template-tx20) (#16969) --- esphome/components/template/lock/automation.h | 2 +- esphome/components/template/valve/automation.h | 2 +- .../template/water_heater/automation.h | 2 +- .../water_heater/template_water_heater.h | 2 +- esphome/components/text/automation.h | 4 ++-- .../text/text_sensor/text_text_sensor.h | 2 +- esphome/components/text_sensor/automation.h | 8 ++++---- esphome/components/thermopro_ble/thermopro_ble.h | 2 +- .../components/thermostat/thermostat_climate.h | 2 +- esphome/components/time/automation.h | 4 ++-- esphome/components/time/real_time_clock.h | 2 +- .../time_based/cover/time_based_cover.h | 2 +- esphome/components/tinyusb/tinyusb_component.h | 2 +- esphome/components/tlc59208f/tlc59208f_output.h | 4 ++-- .../components/tlc5947/output/tlc5947_output.h | 2 +- esphome/components/tlc5947/tlc5947.h | 2 +- .../components/tlc5971/output/tlc5971_output.h | 2 +- esphome/components/tlc5971/tlc5971.h | 2 +- esphome/components/tm1621/tm1621.h | 2 +- esphome/components/tm1637/tm1637.h | 4 ++-- .../components/tm1638/binary_sensor/tm1638_key.h | 2 +- .../components/tm1638/output/tm1638_output_led.h | 2 +- .../components/tm1638/switch/tm1638_switch_led.h | 2 +- esphome/components/tm1638/tm1638.h | 2 +- esphome/components/tm1651/tm1651.h | 12 ++++++------ esphome/components/tmp102/tmp102.h | 2 +- esphome/components/tmp1075/tmp1075.h | 2 +- esphome/components/tmp117/tmp117.h | 2 +- esphome/components/tof10120/tof10120_sensor.h | 2 +- esphome/components/tormatic/tormatic_cover.h | 2 +- esphome/components/toshiba/toshiba.h | 2 +- .../total_daily_energy/total_daily_energy.h | 2 +- .../binary_sensor/touchscreen_binary_sensor.h | 8 ++++---- esphome/components/tsl2561/tsl2561.h | 2 +- esphome/components/tsl2591/tsl2591.h | 2 +- .../tt21100/binary_sensor/tt21100_button.h | 8 ++++---- esphome/components/tt21100/touchscreen/tt21100.h | 2 +- esphome/components/ttp229_bsf/ttp229_bsf.h | 4 ++-- esphome/components/ttp229_lsf/ttp229_lsf.h | 4 ++-- esphome/components/tuya/automation.h | 16 ++++++++-------- .../tuya/binary_sensor/tuya_binary_sensor.h | 2 +- esphome/components/tuya/climate/tuya_climate.h | 2 +- esphome/components/tuya/cover/tuya_cover.h | 2 +- esphome/components/tuya/fan/tuya_fan.h | 2 +- esphome/components/tuya/light/tuya_light.h | 2 +- esphome/components/tuya/number/tuya_number.h | 2 +- esphome/components/tuya/select/tuya_select.h | 2 +- esphome/components/tuya/sensor/tuya_sensor.h | 2 +- esphome/components/tuya/switch/tuya_switch.h | 2 +- .../tuya/text_sensor/tuya_text_sensor.h | 2 +- esphome/components/tuya/tuya.h | 2 +- esphome/components/tx20/tx20.h | 2 +- 52 files changed, 79 insertions(+), 79 deletions(-) diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h index 42a2a826e2a..a979291b785 100644 --- a/esphome/components/template/lock/automation.h +++ b/esphome/components/template/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateLockPublishAction : public Action, public Parented { +template class TemplateLockPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(lock::LockState, state) diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h index a27e98b25c7..ec9d784ab65 100644 --- a/esphome/components/template/valve/automation.h +++ b/esphome/components/template/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateValvePublishAction : public Action, public Parented { +template class TemplateValvePublishAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, position) TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) diff --git a/esphome/components/template/water_heater/automation.h b/esphome/components/template/water_heater/automation.h index d19542db41c..3301a15af1e 100644 --- a/esphome/components/template/water_heater/automation.h +++ b/esphome/components/template/water_heater/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { template -class TemplateWaterHeaterPublishAction : public Action, public Parented { +class TemplateWaterHeaterPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, current_temperature) TEMPLATABLE_VALUE(float, target_temperature) diff --git a/esphome/components/template/water_heater/template_water_heater.h b/esphome/components/template/water_heater/template_water_heater.h index 045a142e406..7d5f1985532 100644 --- a/esphome/components/template/water_heater/template_water_heater.h +++ b/esphome/components/template/water_heater/template_water_heater.h @@ -13,7 +13,7 @@ enum TemplateWaterHeaterRestoreMode { WATER_HEATER_RESTORE_AND_CALL, }; -class TemplateWaterHeater : public Component, public water_heater::WaterHeater { +class TemplateWaterHeater final : public Component, public water_heater::WaterHeater { public: TemplateWaterHeater(); diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index ac8166d0bec..916d86340d4 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -6,14 +6,14 @@ namespace esphome::text { -class TextStateTrigger : public Trigger { +class TextStateTrigger final : public Trigger { public: explicit TextStateTrigger(Text *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSetAction : public Action { +template class TextSetAction final : public Action { public: explicit TextSetAction(Text *text) : text_(text) {} TEMPLATABLE_VALUE(std::string, value) diff --git a/esphome/components/text/text_sensor/text_text_sensor.h b/esphome/components/text/text_sensor/text_text_sensor.h index fd70ea3451a..59fa04a75e2 100644 --- a/esphome/components/text/text_sensor/text_text_sensor.h +++ b/esphome/components/text/text_sensor/text_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::text { -class TextTextSensor : public text_sensor::TextSensor, public Component { +class TextTextSensor final : public text_sensor::TextSensor, public Component { public: explicit TextTextSensor(Text *source) : source_(source) {} void setup() override; diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index ab303627742..628b9b84a0f 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -8,21 +8,21 @@ namespace esphome::text_sensor { -class TextSensorStateTrigger : public Trigger { +class TextSensorStateTrigger final : public Trigger { public: explicit TextSensorStateTrigger(TextSensor *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -class TextSensorStateRawTrigger : public Trigger { +class TextSensorStateRawTrigger final : public Trigger { public: explicit TextSensorStateRawTrigger(TextSensor *parent) { parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSensorStateCondition : public Condition { +template class TextSensorStateCondition final : public Condition { public: explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} @@ -34,7 +34,7 @@ template class TextSensorStateCondition : public Condition class TextSensorPublishAction : public Action { +template class TextSensorPublishAction final : public Action { public: TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(std::string, state) diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 38bed821025..2d7523e07af 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -17,7 +17,7 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4268d5c5822..f30659a8a61 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -81,7 +81,7 @@ struct ThermostatCustomPresetEntry { ThermostatClimateTargetTempConfig config; }; -class ThermostatClimate : public climate::Climate, public Component { +class ThermostatClimate final : public climate::Climate, public Component { public: using PresetEntry = ThermostatPresetEntry; using CustomPresetEntry = ThermostatCustomPresetEntry; diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 546c4a10de2..7be195903ac 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -10,7 +10,7 @@ namespace esphome::time { -class CronTrigger : public Trigger<>, public Component { +class CronTrigger final : public Trigger<>, public Component { public: explicit CronTrigger(RealTimeClock *rtc); void add_second(uint8_t second); @@ -41,7 +41,7 @@ class CronTrigger : public Trigger<>, public Component { optional last_check_; }; -class SyncTrigger : public Trigger<>, public Component { +class SyncTrigger final : public Trigger<>, public Component { public: explicit SyncTrigger(RealTimeClock *rtc); diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 06ee2ea5af4..7a9175f39cf 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -72,7 +72,7 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition : public Condition { +template class TimeHasTimeCondition final : public Condition { public: TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } diff --git a/esphome/components/time_based/cover/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h index ce0b105cebe..1e1f51ff23f 100644 --- a/esphome/components/time_based/cover/time_based_cover.h +++ b/esphome/components/time_based/cover/time_based_cover.h @@ -6,7 +6,7 @@ namespace esphome::time_based { -class TimeBasedCover : public cover::Cover, public Component { +class TimeBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 7ec3da118c1..e85fea9d21a 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -20,7 +20,7 @@ enum USBDStringDescriptor : uint8_t { static const char *const DEFAULT_USB_STR = "ESPHome"; -class TinyUSB : public Component { +class TinyUSB final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tlc59208f/tlc59208f_output.h b/esphome/components/tlc59208f/tlc59208f_output.h index 46f88de01f4..678e309f59e 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.h +++ b/esphome/components/tlc59208f/tlc59208f_output.h @@ -21,7 +21,7 @@ inline constexpr uint8_t TLC59208F_MODE2_WDT_35MS = (3 << 0); class TLC59208FOutput; -class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC59208FChannel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -34,7 +34,7 @@ class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC5947Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5947/tlc5947.h b/esphome/components/tlc5947/tlc5947.h index 18acffa25f4..a9519d784fe 100644 --- a/esphome/components/tlc5947/tlc5947.h +++ b/esphome/components/tlc5947/tlc5947.h @@ -9,7 +9,7 @@ namespace esphome::tlc5947 { -class TLC5947 : public Component { +class TLC5947 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 24; diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index 2a24a19b6c4..fd0f5e82b52 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -8,7 +8,7 @@ namespace esphome::tlc5971 { -class TLC5971Channel : public output::FloatOutput, public Parented { +class TLC5971Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5971/tlc5971.h b/esphome/components/tlc5971/tlc5971.h index 080249c89c2..75e4c57027a 100644 --- a/esphome/components/tlc5971/tlc5971.h +++ b/esphome/components/tlc5971/tlc5971.h @@ -9,7 +9,7 @@ namespace esphome::tlc5971 { -class TLC5971 : public Component { +class TLC5971 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 12; diff --git a/esphome/components/tm1621/tm1621.h b/esphome/components/tm1621/tm1621.h index 7708ee6c988..806e69f9938 100644 --- a/esphome/components/tm1621/tm1621.h +++ b/esphome/components/tm1621/tm1621.h @@ -11,7 +11,7 @@ class TM1621Display; using tm1621_writer_t = display::DisplayWriter; -class TM1621Display : public PollingComponent { +class TM1621Display final : public PollingComponent { public: void set_writer(tm1621_writer_t &&writer) { this->writer_ = writer; } diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index 1ad56ae75a3..a3dd50fb37a 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -21,7 +21,7 @@ class TM1637Key; using tm1637_writer_t = display::DisplayWriter; -class TM1637Display : public PollingComponent { +class TM1637Display final : public PollingComponent { public: void set_writer(tm1637_writer_t &&writer) { this->writer_ = writer; } @@ -92,7 +92,7 @@ class TM1637Display : public PollingComponent { }; #ifdef USE_BINARY_SENSOR -class TM1637Key : public binary_sensor::BinarySensor { +class TM1637Key final : public binary_sensor::BinarySensor { friend class TM1637Display; public: diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h index fba1e43bde0..1e6336a1f4a 100644 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ b/esphome/components/tm1638/binary_sensor/tm1638_key.h @@ -5,7 +5,7 @@ namespace esphome::tm1638 { -class TM1638Key : public binary_sensor::BinarySensor, public KeyListener { +class TM1638Key final : public binary_sensor::BinarySensor, public KeyListener { public: void set_keycode(uint8_t key_code) { key_code_ = key_code; }; void keys_update(uint8_t keys) override; diff --git a/esphome/components/tm1638/output/tm1638_output_led.h b/esphome/components/tm1638/output/tm1638_output_led.h index b1c1090447b..e0bf5d31d91 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.h +++ b/esphome/components/tm1638/output/tm1638_output_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638OutputLed : public output::BinaryOutput, public Component { +class TM1638OutputLed final : public output::BinaryOutput, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.h b/esphome/components/tm1638/switch/tm1638_switch_led.h index c7154eefb38..8df4678d62d 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.h +++ b/esphome/components/tm1638/switch/tm1638_switch_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638SwitchLed : public switch_::Switch, public Component { +class TM1638SwitchLed final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index 24d49f4a9f5..9ebea050893 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -20,7 +20,7 @@ class TM1638Component; using tm1638_writer_t = display::DisplayWriter; -class TM1638Component : public PollingComponent { +class TM1638Component final : public PollingComponent { public: void set_writer(tm1638_writer_t &&writer) { this->writer_ = writer; } void setup() override; diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index f1abbcc7922..2021f902668 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -12,7 +12,7 @@ enum TM1651Brightness : uint8_t { TM1651_BRIGHTEST = 3, }; -class TM1651Display : public Component { +class TM1651Display final : public Component { public: void set_clk_pin(InternalGPIOPin *pin) { clk_pin_ = pin; } void set_dio_pin(InternalGPIOPin *pin) { dio_pin_ = pin; } @@ -56,7 +56,7 @@ class TM1651Display : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) @@ -66,7 +66,7 @@ template class SetBrightnessAction : public Action, publi } }; -template class SetLevelAction : public Action, public Parented { +template class SetLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -76,7 +76,7 @@ template class SetLevelAction : public Action, public Par } }; -template class SetLevelPercentAction : public Action, public Parented { +template class SetLevelPercentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level_percent) @@ -86,12 +86,12 @@ template class SetLevelPercentAction : public Action, pub } }; -template class TurnOnAction : public Action, public Parented { +template class TurnOnAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_on(); } }; -template class TurnOffAction : public Action, public Parented { +template class TurnOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_off(); } }; diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index aedfefd0528..f9eda32e989 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,7 +6,7 @@ namespace esphome::tmp102 { -class TMP102Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/tmp1075/tmp1075.h b/esphome/components/tmp1075/tmp1075.h index 4dc9449597a..519d48ad29d 100644 --- a/esphome/components/tmp1075/tmp1075.h +++ b/esphome/components/tmp1075/tmp1075.h @@ -52,7 +52,7 @@ enum EAlertFunction { ALERT_INTERRUPT = 1, }; -class TMP1075Sensor : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class TMP1075Sensor final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/tmp117/tmp117.h b/esphome/components/tmp117/tmp117.h index a8fe7ac7cef..a42a14ca739 100644 --- a/esphome/components/tmp117/tmp117.h +++ b/esphome/components/tmp117/tmp117.h @@ -6,7 +6,7 @@ namespace esphome::tmp117 { -class TMP117Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP117Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tof10120/tof10120_sensor.h b/esphome/components/tof10120/tof10120_sensor.h index 8bf92b50a06..932b89ce496 100644 --- a/esphome/components/tof10120/tof10120_sensor.h +++ b/esphome/components/tof10120/tof10120_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tof10120 { -class TOF10120Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TOF10120Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 2a83213ffec..bde5525d106 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -9,7 +9,7 @@ namespace esphome::tormatic { using namespace esphome::cover; -class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingComponent { +class Tormatic final : public cover::Cover, public uart::UARTDevice, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index 4525d6bffef..a853730f31a 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -23,7 +23,7 @@ const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MAX = 86.0; const float TOSHIBA_RAS_2819T_TEMP_C_MIN = 18.0; const float TOSHIBA_RAS_2819T_TEMP_C_MAX = 30.0; -class ToshibaClimate : public climate_ir::ClimateIR { +class ToshibaClimate final : public climate_ir::ClimateIR { public: ToshibaClimate() : climate_ir::ClimateIR(TOSHIBA_GENERIC_TEMP_C_MIN, TOSHIBA_GENERIC_TEMP_C_MAX, 1.0f, true, true, diff --git a/esphome/components/total_daily_energy/total_daily_energy.h b/esphome/components/total_daily_energy/total_daily_energy.h index 9a20ecea017..a683d43d0f1 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.h +++ b/esphome/components/total_daily_energy/total_daily_energy.h @@ -14,7 +14,7 @@ enum TotalDailyEnergyMethod { TOTAL_DAILY_ENERGY_METHOD_RIGHT, }; -class TotalDailyEnergy : public sensor::Sensor, public Component { +class TotalDailyEnergy final : public sensor::Sensor, public Component { public: void set_restore(bool restore) { restore_ = restore; } void set_time(time::RealTimeClock *time) { time_ = time; } diff --git a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h index 2f86bc97498..f95c7a82b1b 100644 --- a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +++ b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h @@ -10,10 +10,10 @@ namespace esphome::touchscreen { -class TouchscreenBinarySensor : public binary_sensor::BinarySensor, - public Component, - public TouchListener, - public Parented { +class TouchscreenBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public TouchListener, + public Parented { public: void setup() override; diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 0fbb59c648d..8997d19f53a 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -26,7 +26,7 @@ enum TSL2561Gain { }; /// This class includes support for the TSL2561 i2c ambient light sensor. -class TSL2561Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: /** Set the time that sensor values should be accumulated for. * diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 4b63c8ec40a..3fde3404124 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -63,7 +63,7 @@ enum TSL2591SensorChannel { /// light. They are reported as separate sensors, and the difference /// between the values is reported as a third sensor as a convenience /// for visible light only. -class TSL2591Component : public PollingComponent, public i2c::I2CDevice { +class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { public: /** Set device integration time and gain. * diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.h b/esphome/components/tt21100/binary_sensor/tt21100_button.h index a1f59464479..f4073caf3d2 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.h +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.h @@ -7,10 +7,10 @@ namespace esphome::tt21100 { -class TT21100Button : public binary_sensor::BinarySensor, - public Component, - public TT21100ButtonListener, - public Parented { +class TT21100Button final : public binary_sensor::BinarySensor, + public Component, + public TT21100ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tt21100/touchscreen/tt21100.h b/esphome/components/tt21100/touchscreen/tt21100.h index 3c6030c9c1b..31af9085b5b 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.h +++ b/esphome/components/tt21100/touchscreen/tt21100.h @@ -16,7 +16,7 @@ class TT21100ButtonListener { virtual void update_button(uint8_t index, uint16_t state) = 0; }; -class TT21100Touchscreen : public Touchscreen, public i2c::I2CDevice { +class TT21100Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.h b/esphome/components/ttp229_bsf/ttp229_bsf.h index 07f0c638c2d..109764e51dc 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.h +++ b/esphome/components/ttp229_bsf/ttp229_bsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_bsf { -class TTP229BSFChannel : public binary_sensor::BinarySensor { +class TTP229BSFChannel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229BSFChannel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229BSFComponent : public Component { +class TTP229BSFComponent final : public Component { public: void set_sdo_pin(GPIOPin *sdo_pin) { sdo_pin_ = sdo_pin; } void set_scl_pin(GPIOPin *scl_pin) { scl_pin_ = scl_pin; } diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 09e7745d256..50e2baa7f7a 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_lsf { -class TTP229Channel : public binary_sensor::BinarySensor { +class TTP229Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229Channel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229LSFComponent : public Component, public i2c::I2CDevice { +class TTP229LSFComponent final : public Component, public i2c::I2CDevice { public: void register_channel(TTP229Channel *channel) { this->channels_.push_back(channel); } void setup() override; diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h index f5c806b0135..0cd63a76be7 100644 --- a/esphome/components/tuya/automation.h +++ b/esphome/components/tuya/automation.h @@ -8,44 +8,44 @@ namespace esphome::tuya { -class TuyaDatapointUpdateTrigger : public Trigger { +class TuyaDatapointUpdateTrigger final : public Trigger { public: explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); } }; -class TuyaRawDatapointUpdateTrigger : public Trigger> { +class TuyaRawDatapointUpdateTrigger final : public Trigger> { public: explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBoolDatapointUpdateTrigger : public Trigger { +class TuyaBoolDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaIntDatapointUpdateTrigger : public Trigger { +class TuyaIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaUIntDatapointUpdateTrigger : public Trigger { +class TuyaUIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaStringDatapointUpdateTrigger : public Trigger { +class TuyaStringDatapointUpdateTrigger final : public Trigger { public: explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaEnumDatapointUpdateTrigger : public Trigger { +class TuyaEnumDatapointUpdateTrigger final : public Trigger { public: explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBitmaskDatapointUpdateTrigger : public Trigger { +class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h index f92652d087b..76d7da46040 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaBinarySensor : public binary_sensor::BinarySensor, public Component { +class TuyaBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index b9fb45257aa..015da1930cb 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaClimate : public climate::Climate, public Component { +class TuyaClimate final : public climate::Climate, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tuya/cover/tuya_cover.h b/esphome/components/tuya/cover/tuya_cover.h index ab639756832..fb38c813775 100644 --- a/esphome/components/tuya/cover/tuya_cover.h +++ b/esphome/components/tuya/cover/tuya_cover.h @@ -12,7 +12,7 @@ enum TuyaCoverRestoreMode { COVER_RESTORE_AND_CALL, }; -class TuyaCover : public cover::Cover, public Component { +class TuyaCover final : public cover::Cover, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/fan/tuya_fan.h b/esphome/components/tuya/fan/tuya_fan.h index bfb6bdeca03..70b127c10e8 100644 --- a/esphome/components/tuya/fan/tuya_fan.h +++ b/esphome/components/tuya/fan/tuya_fan.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaFan : public Component, public fan::Fan { +class TuyaFan final : public Component, public fan::Fan { public: TuyaFan(Tuya *parent, int speed_count) : parent_(parent), speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/tuya/light/tuya_light.h b/esphome/components/tuya/light/tuya_light.h index d990eea72ad..c921efc145c 100644 --- a/esphome/components/tuya/light/tuya_light.h +++ b/esphome/components/tuya/light/tuya_light.h @@ -8,7 +8,7 @@ namespace esphome::tuya { enum TuyaColorType { RGB, HSV, RGBHSV }; -class TuyaLight : public Component, public light::LightOutput { +class TuyaLight final : public Component, public light::LightOutput { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/number/tuya_number.h b/esphome/components/tuya/number/tuya_number.h index 51c53a4442e..a7289bb8031 100644 --- a/esphome/components/tuya/number/tuya_number.h +++ b/esphome/components/tuya/number/tuya_number.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaNumber : public number::Number, public Component { +class TuyaNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/select/tuya_select.h b/esphome/components/tuya/select/tuya_select.h index f8d2d89ea89..4da01411b7a 100644 --- a/esphome/components/tuya/select/tuya_select.h +++ b/esphome/components/tuya/select/tuya_select.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaSelect : public select::Select, public Component { +class TuyaSelect final : public select::Select, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/sensor/tuya_sensor.h b/esphome/components/tuya/sensor/tuya_sensor.h index b700fc8bd74..65f9dc599a1 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.h +++ b/esphome/components/tuya/sensor/tuya_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSensor : public sensor::Sensor, public Component { +class TuyaSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/switch/tuya_switch.h b/esphome/components/tuya/switch/tuya_switch.h index 7e0109c34c9..4cd137a6c83 100644 --- a/esphome/components/tuya/switch/tuya_switch.h +++ b/esphome/components/tuya/switch/tuya_switch.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSwitch : public switch_::Switch, public Component { +class TuyaSwitch final : public switch_::Switch, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.h b/esphome/components/tuya/text_sensor/tuya_text_sensor.h index c9ac64deb8e..2969bbf74b3 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.h +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaTextSensor : public text_sensor::TextSensor, public Component { +class TuyaTextSensor final : public text_sensor::TextSensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 470b97e7e70..4e7ab5c7f92 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -84,7 +84,7 @@ struct TuyaCommand { std::vector payload; }; -class Tuya : public Component, public uart::UARTDevice { +class Tuya final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/tx20/tx20.h b/esphome/components/tx20/tx20.h index 7ca29eaf3bc..e4dc7dfab01 100644 --- a/esphome/components/tx20/tx20.h +++ b/esphome/components/tx20/tx20.h @@ -21,7 +21,7 @@ struct Tx20ComponentStore { }; /// This class implements support for the Tx20 Wind sensor. -class Tx20Component : public Component { +class Tx20Component final : public Component { public: /// Get the textual representation of the wind direction ('N', 'SSE', ..). std::string get_wind_cardinal_direction() const; From 64acb358a515d26ed2f4e4cad4d4745a110e7e36 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:20:00 +1200 Subject: [PATCH 163/343] Mark configurable classes as final (8/21: hm3301-integration) (#16959) --- esphome/components/hm3301/hm3301.h | 2 +- esphome/components/hmc5883l/hmc5883l.h | 2 +- .../binary_sensor/homeassistant_binary_sensor.h | 2 +- .../components/homeassistant/number/homeassistant_number.h | 2 +- .../components/homeassistant/sensor/homeassistant_sensor.h | 2 +- .../components/homeassistant/switch/homeassistant_switch.h | 2 +- .../homeassistant/text_sensor/homeassistant_text_sensor.h | 2 +- esphome/components/honeywell_hih_i2c/honeywell_hih.h | 2 +- esphome/components/honeywellabp/honeywellabp.h | 6 +++--- esphome/components/honeywellabp2_i2c/honeywellabp2.h | 2 +- esphome/components/host/gpio.h | 2 +- esphome/components/host/time/host_time.h | 2 +- esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 2 +- esphome/components/hte501/hte501.h | 2 +- esphome/components/http_request/http_request.h | 4 ++-- esphome/components/http_request/http_request_arduino.h | 2 +- esphome/components/http_request/http_request_host.h | 2 +- esphome/components/http_request/http_request_idf.h | 2 +- esphome/components/http_request/ota/automation.h | 2 +- esphome/components/htu21d/htu21d.h | 6 +++--- esphome/components/htu31d/htu31d.h | 2 +- esphome/components/hub75/hub75_component.h | 4 ++-- esphome/components/hx711/hx711.h | 2 +- esphome/components/hydreon_rgxx/hydreon_rgxx.h | 4 ++-- esphome/components/hyt271/hyt271.h | 2 +- esphome/components/i2c/i2c_bus_arduino.h | 2 +- esphome/components/i2c/i2c_bus_esp_idf.h | 2 +- esphome/components/i2c/i2c_bus_host.h | 2 +- esphome/components/i2c/i2c_bus_zephyr.h | 2 +- esphome/components/i2c_device/i2c_device.h | 2 +- esphome/components/i2s_audio/i2s_audio.h | 2 +- .../components/i2s_audio/microphone/i2s_audio_microphone.h | 2 +- .../i2s_audio/speaker/i2s_audio_speaker_standard.h | 2 +- esphome/components/iaqcore/iaqcore.h | 2 +- esphome/components/improv_serial/improv_serial_component.h | 2 +- esphome/components/ina219/ina219.h | 2 +- esphome/components/ina226/ina226.h | 2 +- esphome/components/ina260/ina260.h | 2 +- esphome/components/ina2xx_i2c/ina2xx_i2c.h | 2 +- esphome/components/ina2xx_spi/ina2xx_spi.h | 6 +++--- esphome/components/ina3221/ina3221.h | 2 +- .../components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h | 2 +- esphome/components/inkplate/inkplate.h | 2 +- esphome/components/integration/integration_sensor.h | 6 +++--- 44 files changed, 55 insertions(+), 55 deletions(-) diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 55e708e34a1..adbd8450ed0 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -9,7 +9,7 @@ namespace esphome::hm3301 { static const uint8_t SELECT_COMM_CMD = 0x88; -class HM3301Component : public PollingComponent, public i2c::I2CDevice { +class HM3301Component final : public PollingComponent, public i2c::I2CDevice { public: HM3301Component() = default; diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index 4f170d74015..d23eb1a0f4a 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -34,7 +34,7 @@ enum HMC5883LRange { HMC5883L_RANGE_810_UT = 0b111, }; -class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class HMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 6d95ea2c609..c713b143af1 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { +class HomeassistantBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index a1e351fdf45..c9673234eeb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -6,7 +6,7 @@ namespace esphome::homeassistant { -class HomeassistantNumber : public number::Number, public Component { +class HomeassistantNumber final : public number::Number, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index afc49355378..e787039ee32 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSensor : public sensor::Sensor, public Component { +class HomeassistantSensor final : public sensor::Sensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index c6c178c205b..3dd1ab1525c 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSwitch : public switch_::Switch, public Component { +class HomeassistantSwitch final : public switch_::Switch, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index 8af81cefcb7..63ec136b574 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { +class HomeassistantTextSensor final : public text_sensor::TextSensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.h b/esphome/components/honeywell_hih_i2c/honeywell_hih.h index d9ea6401ce0..6d02044cfc6 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.h +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.h @@ -7,7 +7,7 @@ namespace esphome::honeywell_hih_i2c { -class HoneywellHIComponent : public PollingComponent, public i2c::I2CDevice { +class HoneywellHIComponent final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/honeywellabp/honeywellabp.h b/esphome/components/honeywellabp/honeywellabp.h index 3c31968c490..067311b8d42 100644 --- a/esphome/components/honeywellabp/honeywellabp.h +++ b/esphome/components/honeywellabp/honeywellabp.h @@ -8,9 +8,9 @@ namespace esphome::honeywellabp { -class HONEYWELLABPSensor : public PollingComponent, - public spi::SPIDevice { +class HONEYWELLABPSensor final : public PollingComponent, + public spi::SPIDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.h b/esphome/components/honeywellabp2_i2c/honeywellabp2.h index 41ea21344bd..70f435f8b01 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.h +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.h @@ -11,7 +11,7 @@ namespace esphome::honeywellabp2_i2c { enum ABP2TRANFERFUNCTION { ABP2_TRANS_FUNC_A = 0, ABP2_TRANS_FUNC_B = 1 }; -class HONEYWELLABP2Sensor : public PollingComponent, public i2c::I2CDevice { +class HONEYWELLABP2Sensor final : public PollingComponent, public i2c::I2CDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; }; void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }; diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index 6f2bccf1021..bd2d09257bb 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -6,7 +6,7 @@ namespace esphome::host { -class HostGPIOPin : public InternalGPIOPin { +class HostGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/host/time/host_time.h b/esphome/components/host/time/host_time.h index 19e1af99d1f..4462108b6d7 100644 --- a/esphome/components/host/time/host_time.h +++ b/esphome/components/host/time/host_time.h @@ -5,7 +5,7 @@ namespace esphome::host { -class HostTime : public time::RealTimeClock { +class HostTime final : public time::RealTimeClock { public: void update() override {} }; diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index e98eeea723b..36346c8293e 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -6,7 +6,7 @@ namespace esphome::hrxl_maxsonar_wr { -class HrxlMaxsonarWrComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 310073f88b9..403d3c1de67 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -7,7 +7,7 @@ namespace esphome::hte501 { /// This class implements support for the hte501 of temperature i2c sensors. -class HTE501Component : public PollingComponent, public i2c::I2CDevice { +class HTE501Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 2477e26bc12..5025a5c12d5 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -311,7 +311,7 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, return {HttpReadStatus::OK, 0}; } -class HttpRequestResponseTrigger : public Trigger, std::string &> { +class HttpRequestResponseTrigger final : public Trigger, std::string &> { public: void process(const std::shared_ptr &container, std::string &response_body) { this->trigger(container, response_body); @@ -447,7 +447,7 @@ class HttpRequestComponent : public Component { uint32_t watchdog_timeout_{0}; }; -template class HttpRequestSendAction : public Action { +template class HttpRequestSendAction final : public Action { public: HttpRequestSendAction(HttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index b009d45b1ca..8da40798eca 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -46,7 +46,7 @@ class HttpContainerArduino : public HttpContainer { size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk }; -class HttpRequestArduino : public HttpRequestComponent { +class HttpRequestArduino final : public HttpRequestComponent { public: #ifdef USE_ESP8266 void set_tls_buffer_size_rx(uint16_t size) { this->tls_buffer_size_rx_ = size; } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 52be0e8a16e..9045702f46a 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -16,7 +16,7 @@ class HttpContainerHost : public HttpContainer { std::vector response_body_{}; }; -class HttpRequestHost : public HttpRequestComponent { +class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 9ed1a97b1ac..8a803b54695 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -26,7 +26,7 @@ class HttpContainerIDF : public HttpContainer { esp_http_client_handle_t client_; }; -class HttpRequestIDF : public HttpRequestComponent { +class HttpRequestIDF final : public HttpRequestComponent { public: void dump_config() override; diff --git a/esphome/components/http_request/ota/automation.h b/esphome/components/http_request/ota/automation.h index f6f49b14b1a..487f6b70a1e 100644 --- a/esphome/components/http_request/ota/automation.h +++ b/esphome/components/http_request/ota/automation.h @@ -5,7 +5,7 @@ namespace esphome::http_request { -template class OtaHttpRequestComponentFlashAction : public Action { +template class OtaHttpRequestComponentFlashAction final : public Action { public: OtaHttpRequestComponentFlashAction(OtaHttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, md5_url) diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index a111722dc79..f86d62c5e8d 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -9,7 +9,7 @@ namespace esphome::htu21d { enum HTU21DSensorModels { HTU21D_SENSOR_MODEL_HTU21D = 0, HTU21D_SENSOR_MODEL_SI7021, HTU21D_SENSOR_MODEL_SHT21 }; -class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -34,7 +34,7 @@ class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction : public Action, public Parented { +template class SetHeaterLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -45,7 +45,7 @@ template class SetHeaterLevelAction : public Action, publ } }; -template class SetHeaterAction : public Action, public Parented { +template class SetHeaterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, status) diff --git a/esphome/components/htu31d/htu31d.h b/esphome/components/htu31d/htu31d.h index 451918cb3bc..c25a979600c 100644 --- a/esphome/components/htu31d/htu31d.h +++ b/esphome/components/htu31d/htu31d.h @@ -7,7 +7,7 @@ namespace esphome::htu31d { -class HTU31DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU31DComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; /// Setup (reset) the sensor and check connection. void update() override; /// Update the sensor values (temperature+humidity). diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index ab7e3fc5b1c..98bc2e52e6f 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -16,7 +16,7 @@ namespace esphome::hub75 { using esphome::display::ColorBitness; using esphome::display::ColorOrder; -class HUB75Display : public display::Display { +class HUB75Display final : public display::Display { public: // Constructor accepting config explicit HUB75Display(const Hub75Config &config); @@ -51,7 +51,7 @@ class HUB75Display : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) diff --git a/esphome/components/hx711/hx711.h b/esphome/components/hx711/hx711.h index 43ab4c0f562..62d0171d8f2 100644 --- a/esphome/components/hx711/hx711.h +++ b/esphome/components/hx711/hx711.h @@ -14,7 +14,7 @@ enum HX711Gain : uint8_t { HX711_GAIN_64 = 3, }; -class HX711Sensor : public sensor::Sensor, public PollingComponent { +class HX711Sensor final : public sensor::Sensor, public PollingComponent { public: void set_dout_pin(GPIOPin *dout_pin) { dout_pin_ = dout_pin; } void set_sck_pin(GPIOPin *sck_pin) { sck_pin_ = sck_pin; } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index 2ae46907c15..a7bde6105c6 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -32,7 +32,7 @@ static const uint8_t NUM_SENSORS = 1; #define HYDREON_RGXX_IGNORE_LIST(F, SEP) F("Emitters") SEP F("Event") SEP F("Reset") -class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { +class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDevice { public: void set_sensor(sensor::Sensor *sensor, int index) { this->sensors_[index] = sensor; } #ifdef USE_BINARY_SENSOR @@ -86,7 +86,7 @@ class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { int sensors_received_ = -1; }; -class HydreonRGxxBinaryComponent : public Component { +class HydreonRGxxBinaryComponent final : public Component { public: HydreonRGxxBinaryComponent(HydreonRGxxComponent *parent) {} }; diff --git a/esphome/components/hyt271/hyt271.h b/esphome/components/hyt271/hyt271.h index d08b3779ad3..b373c264663 100644 --- a/esphome/components/hyt271/hyt271.h +++ b/esphome/components/hyt271/hyt271.h @@ -6,7 +6,7 @@ namespace esphome::hyt271 { -class HYT271Component : public PollingComponent, public i2c::I2CDevice { +class HYT271Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index edc14af7bce..ded28dd80c5 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class ArduinoI2CBus : public InternalI2CBus, public Component { +class ArduinoI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index c23f9f0c54f..92e96f649b6 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class IDFI2CBus : public InternalI2CBus, public Component { +class IDFI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8e3aff79774..8064016a520 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -8,7 +8,7 @@ namespace esphome::i2c { -class HostI2CBus : public I2CBus, public Component { +class HostI2CBus final : public I2CBus, public Component { public: ~HostI2CBus() override; diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3c4aa9ed1d8..3ada1e0a0f2 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -9,7 +9,7 @@ struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephy namespace esphome::i2c { -class ZephyrI2CBus : public InternalI2CBus, public Component { +class ZephyrI2CBus final : public InternalI2CBus, public Component { public: explicit ZephyrI2CBus(const device *i2c_dev) : i2c_dev_(i2c_dev) {} void setup() override; diff --git a/esphome/components/i2c_device/i2c_device.h b/esphome/components/i2c_device/i2c_device.h index aeae622c2e8..d5a49a2caa8 100644 --- a/esphome/components/i2c_device/i2c_device.h +++ b/esphome/components/i2c_device/i2c_device.h @@ -5,7 +5,7 @@ namespace esphome::i2c_device { -class I2CDeviceComponent : public Component, public i2c::I2CDevice { +class I2CDeviceComponent final : public Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 6b32b556d9b..00a9705807e 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -36,7 +36,7 @@ class I2SAudioIn : public I2SAudioBase {}; class I2SAudioOut : public I2SAudioBase {}; -class I2SAudioComponent : public Component { +class I2SAudioComponent final : public Component { public: i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 06f2de76107..65ad7df1af4 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -14,7 +14,7 @@ namespace esphome::i2s_audio { -class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, public Component { +class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphone, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 7b7f8b647d7..4b52dcd52a9 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -14,7 +14,7 @@ enum class I2SCommFmt : uint8_t { /// @brief Standard I2S speaker implementation. /// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. -class I2SAudioSpeaker : public I2SAudioSpeakerBase { +class I2SAudioSpeaker final : public I2SAudioSpeakerBase { public: void dump_config() override; diff --git a/esphome/components/iaqcore/iaqcore.h b/esphome/components/iaqcore/iaqcore.h index 39f290e1205..6fdf9cbce88 100644 --- a/esphome/components/iaqcore/iaqcore.h +++ b/esphome/components/iaqcore/iaqcore.h @@ -6,7 +6,7 @@ namespace esphome::iaqcore { -class IAQCore : public PollingComponent, public i2c::I2CDevice { +class IAQCore final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 70f9214e2d8..4df6f6df2d2 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -44,7 +44,7 @@ enum ImprovSerialType : uint8_t { static const uint16_t IMPROV_SERIAL_TIMEOUT = 100; static const uint8_t IMPROV_SERIAL_VERSION = 1; -class ImprovSerialComponent : public Component, public improv_base::ImprovBase { +class ImprovSerialComponent final : public Component, public improv_base::ImprovBase { public: void setup() override; void loop() override; diff --git a/esphome/components/ina219/ina219.h b/esphome/components/ina219/ina219.h index 7462c072724..a78c1653f4e 100644 --- a/esphome/components/ina219/ina219.h +++ b/esphome/components/ina219/ina219.h @@ -8,7 +8,7 @@ namespace esphome::ina219 { -class INA219Component : public PollingComponent, public i2c::I2CDevice { +class INA219Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 7d6b526f40f..00d62fad760 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -40,7 +40,7 @@ union ConfigurationRegister { } __attribute__((packed)); }; -class INA226Component : public PollingComponent, public i2c::I2CDevice { +class INA226Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index 856e715774c..bbcb7a7acb2 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -6,7 +6,7 @@ namespace esphome::ina260 { -class INA260Component : public PollingComponent, public i2c::I2CDevice { +class INA260Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.h b/esphome/components/ina2xx_i2c/ina2xx_i2c.h index 783723b3961..d9945be5efc 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.h +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ina2xx_i2c { -class INA2XXI2C : public ina2xx_base::INA2XX, public i2c::I2CDevice { +class INA2XXI2C final : public ina2xx_base::INA2XX, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.h b/esphome/components/ina2xx_spi/ina2xx_spi.h index 8e065de8169..efe9cf257d3 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.h +++ b/esphome/components/ina2xx_spi/ina2xx_spi.h @@ -6,9 +6,9 @@ namespace esphome::ina2xx_spi { -class INA2XXSPI : public ina2xx_base::INA2XX, - public spi::SPIDevice { +class INA2XXSPI final : public ina2xx_base::INA2XX, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 9d9762caf37..48226c743a9 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -6,7 +6,7 @@ namespace esphome::ina3221 { -class INA3221Component : public PollingComponent, public i2c::I2CDevice { +class INA3221Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 37e50943f33..4c90d6d35bf 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -8,7 +8,7 @@ namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/inkplate/inkplate.h b/esphome/components/inkplate/inkplate.h index 40e32c4cc44..4f9f4109eeb 100644 --- a/esphome/components/inkplate/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -31,7 +31,7 @@ static constexpr uint8_t LUTB[16] = {0xFF, 0xFD, 0xF7, 0xF5, 0xDF, 0xDD, 0xD7, 0 static constexpr uint8_t PIXEL_MASK_LUT[8] = {0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80}; static constexpr uint8_t PIXEL_MASK_GLUT[2] = {0x0F, 0xF0}; -class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { +class Inkplate final : public display::DisplayBuffer, public i2c::I2CDevice { public: void set_greyscale(bool greyscale) { this->greyscale_ = greyscale; diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 1c5edfcba54..019c3ee0740 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -22,7 +22,7 @@ enum IntegrationMethod { INTEGRATION_METHOD_RIGHT, }; -class IntegrationSensor : public sensor::Sensor, public Component { +class IntegrationSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; @@ -71,12 +71,12 @@ class IntegrationSensor : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetValueAction : public Action, public Parented { +template class SetValueAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, value) From e5d8c22b47ae2d15ba5ea84c4499d1e8b963332b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:20:11 +1200 Subject: [PATCH 164/343] Mark configurable classes as final (13/21: pmsa003i-rc522) (#16964) --- esphome/components/pmsa003i/pmsa003i.h | 2 +- esphome/components/pmsx003/pmsx003.h | 2 +- esphome/components/pmwcs3/pmwcs3.h | 8 +++---- esphome/components/pn532/pn532.h | 4 ++-- esphome/components/pn532_i2c/pn532_i2c.h | 2 +- esphome/components/pn532_spi/pn532_spi.h | 6 ++--- esphome/components/pn7150/automation.h | 22 +++++++++---------- esphome/components/pn7150_i2c/pn7150_i2c.h | 2 +- esphome/components/pn7160/automation.h | 22 +++++++++---------- esphome/components/pn7160_i2c/pn7160_i2c.h | 2 +- esphome/components/pn7160_spi/pn7160_spi.h | 6 ++--- .../components/power_supply/power_supply.h | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/psram/psram.h | 2 +- esphome/components/pulse_counter/automation.h | 2 +- .../pulse_counter/pulse_counter_sensor.h | 2 +- esphome/components/pulse_meter/automation.h | 2 +- .../pulse_meter/pulse_meter_sensor.h | 2 +- esphome/components/pulse_width/pulse_width.h | 2 +- .../pvvx_mithermometer/display/pvvx_display.h | 2 +- .../pvvx_mithermometer/pvvx_mithermometer.h | 2 +- esphome/components/pylontech/pylontech.h | 2 +- .../pylontech/sensor/pylontech_sensor.h | 2 +- .../text_sensor/pylontech_text_sensor.h | 2 +- esphome/components/pzem004t/pzem004t.h | 2 +- esphome/components/pzemac/pzemac.h | 4 ++-- esphome/components/pzemdc/pzemdc.h | 4 ++-- esphome/components/qmc5883l/qmc5883l.h | 2 +- esphome/components/qmp6988/qmp6988.h | 2 +- esphome/components/qr_code/qr_code.h | 2 +- esphome/components/qspi_dbi/qspi_dbi.h | 6 ++--- esphome/components/qwiic_pir/qwiic_pir.h | 2 +- .../radon_eye_ble/radon_eye_listener.h | 2 +- .../radon_eye_rd200/radon_eye_rd200.h | 2 +- esphome/components/rc522/rc522.h | 4 ++-- 35 files changed, 68 insertions(+), 68 deletions(-) diff --git a/esphome/components/pmsa003i/pmsa003i.h b/esphome/components/pmsa003i/pmsa003i.h index aebe80b711d..908b073be1e 100644 --- a/esphome/components/pmsa003i/pmsa003i.h +++ b/esphome/components/pmsa003i/pmsa003i.h @@ -26,7 +26,7 @@ struct PM25AQIData { uint16_t checksum; ///< Packet checksum }; -class PMSA003IComponent : public PollingComponent, public i2c::I2CDevice { +class PMSA003IComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index d559f2dec00..c62960e7c31 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -29,7 +29,7 @@ enum class State : uint8_t { WAITING, }; -class PMSX003Component : public uart::UARTDevice, public Component { +class PMSX003Component final : public uart::UARTDevice, public Component { public: PMSX003Component() = default; void setup() override; diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index d6691478197..4ce4a5ce9c7 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -9,7 +9,7 @@ namespace esphome::pmwcs3 { -class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { +class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; @@ -32,7 +32,7 @@ class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction : public Action { +template class PMWCS3AirCalibrationAction final : public Action { public: PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -42,7 +42,7 @@ template class PMWCS3AirCalibrationAction : public Action PMWCS3Component *parent_; }; -template class PMWCS3WaterCalibrationAction : public Action { +template class PMWCS3WaterCalibrationAction final : public Action { public: PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -52,7 +52,7 @@ template class PMWCS3WaterCalibrationAction : public Action class PMWCS3NewI2cAddressAction : public Action { +template class PMWCS3NewI2cAddressAction final : public Action { public: PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} TEMPLATABLE_VALUE(int, new_address) diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index a26f27ed54c..629a697aa59 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -114,7 +114,7 @@ class PN532 : public PollingComponent { CallbackManager on_finished_write_callback_; }; -class PN532BinarySensor : public binary_sensor::BinarySensor { +class PN532BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const nfc::NfcTagUid &uid) { uid_ = uid; } @@ -132,7 +132,7 @@ class PN532BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition : public Condition, public Parented { +template class PN532IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index b2a2ac2e189..6495f175999 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn532_i2c { -class PN532I2C : public pn532::PN532, public i2c::I2CDevice { +class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index 2bfd4accf7d..f29950c423c 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -8,9 +8,9 @@ namespace esphome::pn532_spi { -class PN532Spi : public pn532::PN532, - public spi::SPIDevice { +class PN532Spi final : public pn532::PN532, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h index 0b2e5f5d247..c3f8d3e5d38 100644 --- a/esphome/components/pn7150/automation.h +++ b/esphome/components/pn7150/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7150 { -template class PN7150IsWritingCondition : public Condition, public Parented { +template class PN7150IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.h b/esphome/components/pn7150_i2c/pn7150_i2c.h index 2ea8c8f75ce..25b0f3b8555 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.h +++ b/esphome/components/pn7150_i2c/pn7150_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7150_i2c { -class PN7150I2C : public pn7150::PN7150, public i2c::I2CDevice { +class PN7150I2C final : public pn7150::PN7150, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h index 7300c4a8d6e..9f03a5a3d63 100644 --- a/esphome/components/pn7160/automation.h +++ b/esphome/components/pn7160/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7160 { -template class PN7160IsWritingCondition : public Condition, public Parented { +template class PN7160IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.h b/esphome/components/pn7160_i2c/pn7160_i2c.h index d29fd04fac4..2a3b7677652 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.h +++ b/esphome/components/pn7160_i2c/pn7160_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7160_i2c { -class PN7160I2C : public pn7160::PN7160, public i2c::I2CDevice { +class PN7160I2C final : public pn7160::PN7160, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160_spi/pn7160_spi.h b/esphome/components/pn7160_spi/pn7160_spi.h index 2d9c1fda117..4f22e5edecc 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.h +++ b/esphome/components/pn7160_spi/pn7160_spi.h @@ -12,9 +12,9 @@ namespace esphome::pn7160_spi { static constexpr uint8_t TDD_SPI_READ = 0xFF; static constexpr uint8_t TDD_SPI_WRITE = 0x0A; -class PN7160Spi : public pn7160::PN7160, - public spi::SPIDevice { +class PN7160Spi final : public pn7160::PN7160, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index e096f69e3bc..eaf77af32e9 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -7,7 +7,7 @@ namespace esphome::power_supply { -class PowerSupply : public Component { +class PowerSupply final : public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_enable_time(uint32_t enable_time) { enable_time_ = enable_time; } diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 008081f5865..bc256c68854 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -14,7 +14,7 @@ namespace esphome::prometheus { -class PrometheusHandler : public AsyncWebHandler, public Component { +class PrometheusHandler final : public AsyncWebHandler, public Component { public: PrometheusHandler(web_server_base::WebServerBase *base) : base_(base) {} diff --git a/esphome/components/psram/psram.h b/esphome/components/psram/psram.h index 22a49588b42..8549ef29595 100644 --- a/esphome/components/psram/psram.h +++ b/esphome/components/psram/psram.h @@ -6,7 +6,7 @@ namespace esphome::psram { -class PsramComponent : public Component { +class PsramComponent final : public Component { void dump_config() override; }; diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h index 14264e87b36..380ef023049 100644 --- a/esphome/components/pulse_counter/automation.h +++ b/esphome/components/pulse_counter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_counter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h index 4f23ef15483..6704d3dc310 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.h +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -59,7 +59,7 @@ struct HwPulseCounterStorage : public PulseCounterStorageBase { PulseCounterStorageBase *get_storage(bool hw_pcnt = false); -class PulseCounterSensor : public sensor::Sensor, public PollingComponent { +class PulseCounterSensor final : public sensor::Sensor, public PollingComponent { public: explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {} diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h index 1def89c3d30..885922a22ac 100644 --- a/esphome/components/pulse_meter/automation.h +++ b/esphome/components/pulse_meter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_meter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.h b/esphome/components/pulse_meter/pulse_meter_sensor.h index 243a64bf053..9fc99a440b9 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.h +++ b/esphome/components/pulse_meter/pulse_meter_sensor.h @@ -9,7 +9,7 @@ namespace esphome::pulse_meter { -class PulseMeterSensor : public sensor::Sensor, public Component { +class PulseMeterSensor final : public sensor::Sensor, public Component { public: enum InternalFilterMode { FILTER_EDGE = 0, diff --git a/esphome/components/pulse_width/pulse_width.h b/esphome/components/pulse_width/pulse_width.h index f77766a9615..7a79b806782 100644 --- a/esphome/components/pulse_width/pulse_width.h +++ b/esphome/components/pulse_width/pulse_width.h @@ -26,7 +26,7 @@ class PulseWidthSensorStore { volatile uint32_t last_rise_{0}; }; -class PulseWidthSensor : public sensor::Sensor, public PollingComponent { +class PulseWidthSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void setup() override { this->store_.setup(this->pin_); } diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index e1aebae7a5d..d231111c582 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -31,7 +31,7 @@ enum UNIT { using pvvx_writer_t = display::DisplayWriter; -class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { +class PVVXDisplay final : public ble_client::BLEClientNode, public PollingComponent { public: void set_writer(pvvx_writer_t &&writer) { this->writer_ = writer; } void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index b5d6da21eff..382e41d2101 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index 1d86803cc23..eae5e6e7bca 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -21,7 +21,7 @@ class PylontechListener { virtual void dump_config(); }; -class PylontechComponent : public PollingComponent, public uart::UARTDevice { +class PylontechComponent final : public PollingComponent, public uart::UARTDevice { public: PylontechComponent(); diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 36576e83327..1403d3445dd 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechSensor : public PylontechListener { +class PylontechSensor final : public PylontechListener { public: PylontechSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 30921b13f4f..3ba4f1fd4e7 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechTextSensor : public PylontechListener { +class PylontechTextSensor final : public PylontechListener { public: PylontechTextSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pzem004t/pzem004t.h b/esphome/components/pzem004t/pzem004t.h index 71fc1e70ad1..42135f0fbde 100644 --- a/esphome/components/pzem004t/pzem004t.h +++ b/esphome/components/pzem004t/pzem004t.h @@ -6,7 +6,7 @@ namespace esphome::pzem004t { -class PZEM004T : public PollingComponent, public uart::UARTDevice { +class PZEM004T final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 264604fedce..a25a8cb631a 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -38,7 +38,7 @@ class PZEMAC : public PollingComponent, public modbus::ModbusDevice { void reset_energy_(); }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 6a7e8404480..e398330cd36 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -31,7 +31,7 @@ class PZEMDC : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 6b8ffa0f40c..faef423f8cc 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -26,7 +26,7 @@ enum QMC5883LOversampling { QMC5883L_SAMPLING_64 = 0b11, }; -class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 41759478b88..ffea32eb18c 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -67,7 +67,7 @@ using qmp6988_data_t = struct Qmp6988Data { qmp6988_ik_data_t ik; }; -class QMP6988Component : public PollingComponent, public i2c::I2CDevice { +class QMP6988Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/qr_code/qr_code.h b/esphome/components/qr_code/qr_code.h index ab4c587b6de..f8ca1660f6f 100644 --- a/esphome/components/qr_code/qr_code.h +++ b/esphome/components/qr_code/qr_code.h @@ -13,7 +13,7 @@ class Display; } // namespace display namespace qr_code { -class QrCode : public Component { +class QrCode final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color, int scale); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index fa77cc5f762..8a1bf0d4c24 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -53,9 +53,9 @@ enum Model { RM67162, }; -class QspiDbi : public display::DisplayBuffer, - public spi::SPIDevice { +class QspiDbi final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model(const char *model) { this->model_ = model; } void update() override; diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 339632a508a..8d3b8fb321b 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -29,7 +29,7 @@ enum DebounceMode { static const uint8_t QWIIC_PIR_DEVICE_ID = 0x72; -class QwiicPIRComponent : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { +class QwiicPIRComponent final : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { public: void setup() override; void loop() override; diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index ceca736e78c..30e3ccc1ea8 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -7,7 +7,7 @@ namespace esphome::radon_eye_ble { -class RadonEyeListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 48e075c2d66..401402a1371 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -13,7 +13,7 @@ namespace esphome::radon_eye_rd200 { -class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { +class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClientNode { public: RadonEyeRD200(); diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index 45473e04b06..fd3c8196969 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -251,7 +251,7 @@ class RC522 : public PollingComponent { } error_code_{NONE}; }; -class RC522BinarySensor : public binary_sensor::BinarySensor { +class RC522BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const std::vector &uid) { uid_ = uid; } @@ -269,7 +269,7 @@ class RC522BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -class RC522Trigger : public Trigger { +class RC522Trigger final : public Trigger { public: void process(std::vector &data); }; From 46cf052ec5b677e93152169682c115f5a9ec2e7d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:28:43 +1200 Subject: [PATCH 165/343] [config_validation] Fix multicast typo in error message (#17206) --- esphome/config_validation.py | 4 +--- tests/unit_tests/test_config_validation.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0fdce85dc31..b77e22a6fb4 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1494,9 +1494,7 @@ def ipv6address(value): def ipv4address_multi_broadcast(value): address = ipv4address(value) if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))): - raise Invalid( - f"{value} is not a multicasst address nor local broadcast address" - ) + raise Invalid(f"{value} is not a multicast address nor local broadcast address") return address diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2715f9c644c..ea3a4ecb532 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1926,7 +1926,7 @@ def test_ipv4address_multi_broadcast_broadcast() -> None: def test_ipv4address_multi_broadcast_invalid() -> None: - with pytest.raises(Invalid, match="not a multicasst"): + with pytest.raises(Invalid, match="not a multicast"): cv.ipv4address_multi_broadcast("192.168.0.1") From 29a610573037682afab5cd67b3f2321094392d0b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:31:15 +1200 Subject: [PATCH 166/343] [ms8607] Mark configurable classes as final (#17147) --- esphome/components/ms8607/ms8607.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 8f9cc9cb883..f2c4d65f13a 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -10,7 +10,7 @@ namespace esphome::ms8607 { Class for I2CDevice used to communicate with the Humidity sensor on the chip. See MS8607Component instead */ -class MS8607HumidityDevice : public i2c::I2CDevice { +class MS8607HumidityDevice final : public i2c::I2CDevice { public: uint8_t get_address() { return address_; } }; @@ -30,9 +30,9 @@ class MS8607HumidityDevice : public i2c::I2CDevice { - https://github.com/adafruit/Adafruit_MS8607 - https://github.com/sparkfun/SparkFun_PHT_MS8607_Arduino_Library */ -class MS8607Component : public PollingComponent, public i2c::I2CDevice { +class MS8607Component final : public PollingComponent, public i2c::I2CDevice { public: - virtual ~MS8607Component() = default; + ~MS8607Component() = default; void setup() override; void update() override; void dump_config() override; From 4f70f6b2a6e5f4aef59eb2ee179e1aef199abd71 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:36:36 +1000 Subject: [PATCH 167/343] [mipi][mipi_spi] Swap native dimensions for swap_xy hardware transform (#17201) Co-authored-by: Claude Opus 4.8 --- esphome/components/mipi/__init__.py | 36 ++-- esphome/components/mipi_spi/display.py | 16 +- .../mipi_spi/test_padding_and_offsets.py | 167 +++++++++++++++++- 3 files changed, 191 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 2244a316b7c..1d6c8277e8e 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -393,6 +393,16 @@ class DriverChip: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} return {CONF_MIRROR_X, CONF_MIRROR_Y} + def has_hardware_transform(self, config) -> bool: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -423,10 +433,15 @@ class DriverChip: :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] @@ -434,23 +449,19 @@ class DriverChip: if CONF_PAD_WIDTH in dimensions: pad_width = dimensions[CONF_PAD_WIDTH] native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width else: - native_width = self.get_default(CONF_NATIVE_WIDTH, 0) - if native_width == 0: - pad_width = 0 - native_width = width + offset_width - else: - pad_width = native_width - width - offset_width + pad_width = native_width - width - offset_width if CONF_PAD_HEIGHT in dimensions: pad_height = dimensions[CONF_PAD_HEIGHT] native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height else: - native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) - if native_height == 0: - pad_height = 0 - native_height = height + offset_height - else: - pad_height = native_height - height - offset_height + pad_height = native_height - height - offset_height if ( pad_width + offset_width >= native_width or pad_height + offset_height >= native_height @@ -466,7 +477,6 @@ class DriverChip: return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 0231d125297..41624590586 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -295,13 +295,7 @@ def customise_schema(config): raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) @@ -366,13 +360,7 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } + has_hardware_transform = model.has_hardware_transform(config) width, height, offset_width, offset_height, pad_width, pad_height = ( model.get_dimensions(config, not has_hardware_transform) ) diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index 82adf88b7e0..7ae6f0e61f5 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -13,6 +13,16 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) +from esphome.components.mipi import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_HEIGHT, + CONF_OFFSET_WIDTH, + CONF_SWAP_XY, + CONF_WIDTH, +) from esphome.components.mipi_spi.display import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, @@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import ( get_instance, ) from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE -from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.const import ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DISABLED, + CONF_TRANSFORM, + PlatformFramework, +) from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -432,3 +448,152 @@ class TestUserConfiguredPadding: assert config["dimensions"]["width"] == 240 assert config["dimensions"]["height"] == 240 assert config["dimensions"]["pad_height"] == 16 + + +class TestHasHardwareTransform: + """Test DriverChip.has_hardware_transform().""" + + def test_full_transform_model_without_transform_key(self) -> None: + """A model supporting swap_xy uses a hardware transform by default.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({}) is True + + def test_full_transform_model_with_transform_dict(self) -> None: + """A configured (non-disabled) transform still uses the hardware path.""" + model = MODELS["ST7789V"] + assert ( + model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True + ) + + def test_full_transform_model_with_transform_disabled(self) -> None: + """Disabling the transform falls back to software transforms.""" + model = MODELS["ST7789V"] + assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False + + def test_model_without_swap_xy_support(self) -> None: + """Models that cannot swap axes never use a hardware transform.""" + # AXS15231 only supports mirror_x/mirror_y, not swap_xy. + model = MODELS["AXS15231"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + assert model.has_hardware_transform({}) is False + + +class TestSwapXYNativeDimensions: + """Test that native dimensions are swapped when a swap_xy transform is active. + + When explicit dimensions are given in the swapped (rotated) orientation and the + model applies a hardware swap_xy transform, the model's native_width/native_height + defaults must be swapped to match, otherwise padding is computed against the wrong + axis and validation fails. + """ + + def test_explicit_swapped_dimensions_with_swap_xy_transform( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Explicit landscape dimensions on a portrait-native model with swap_xy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ST7789V is natively 240x320 (portrait). Provide landscape dimensions + # together with a swap_xy transform. + model = MODELS["ST7789V"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 320, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + # swap=False because the buffer is laid out in the requested orientation. + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + # Native dims are swapped to 320x240, so padding works out to zero rather + # than going negative (which previously raised "Invalid offsets"). + assert (width, height) == (320, 240) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_explicit_dimensions_without_swap_keeps_native_orientation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Without swap_xy the native dimensions keep their original orientation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + model = MODELS["ST7789V"] + config = { + "model": "ST7789V", + CONF_DIMENSIONS: { + CONF_WIDTH: 240, + CONF_HEIGHT: 320, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: False, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions( + config, swap=False + ) + assert (width, height) == (240, 320) + assert (offset_w, offset_h) == (0, 0) + assert (pad_w, pad_h) == (0, 0) + + def test_swapped_native_dimensions_compute_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Padding is derived from the swapped native size when swap_xy is active.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # ILI9341 is natively 240x320. Request a 300x240 area in landscape; the + # swapped native size is 320x240, leaving 20px of horizontal padding. + model = MODELS["ILI9341"] + assert model.get_default("native_width") == 240 + assert model.get_default("native_height") == 320 + + config = { + "model": "ILI9341", + CONF_DIMENSIONS: { + CONF_WIDTH: 300, + CONF_HEIGHT: 240, + CONF_OFFSET_WIDTH: 0, + CONF_OFFSET_HEIGHT: 0, + }, + CONF_TRANSFORM: { + CONF_SWAP_XY: True, + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + }, + } + + width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False) + assert (width, height) == (300, 240) + # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 + assert pad_w == 20 + assert pad_h == 0 From 18c7f604108bfa7aa49afa905144e5e9c6f2f056 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:19:05 -0400 Subject: [PATCH 168/343] [uart] Validate fixed UART settings at config time for fixed-baud components (#17207) --- esphome/components/bl0940/sensor.py | 11 +++++++++++ esphome/components/midea/climate.py | 5 +++++ esphome/components/pzem004t/sensor.py | 4 ++++ esphome/components/rdm6300/__init__.py | 4 ++++ esphome/components/rf_bridge/__init__.py | 10 ++++++++++ esphome/components/rf_bridge/rf_bridge.cpp | 5 +---- esphome/components/sds011/sds011.cpp | 1 - esphome/components/sds011/sensor.py | 18 ++++++++++++++++++ esphome/components/senseair/senseair.cpp | 1 - esphome/components/senseair/sensor.py | 10 ++++++++++ esphome/components/shelly_dimmer/light.py | 4 ++++ esphome/components/sm300d2/sensor.py | 4 ++++ esphome/components/sm300d2/sm300d2.cpp | 1 - tests/components/bl0940/test.esp32-idf.yaml | 2 +- tests/components/bl0940/test.esp8266-ard.yaml | 2 +- tests/components/bl0940/test.rp2040-ard.yaml | 2 +- tests/components/rf_bridge/test.esp32-idf.yaml | 2 +- .../components/rf_bridge/test.esp8266-ard.yaml | 2 +- .../components/rf_bridge/test.rp2040-ard.yaml | 2 +- 19 files changed, 77 insertions(+), 13 deletions(-) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 992064943b8..96445d5c38e 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -211,6 +211,17 @@ CONFIG_SCHEMA = ( .add_extra(set_reference_values) ) +# BL0940 datasheet: 4800 baud, 8 data bits, no parity (stop bits are 1.5 -- not +# representable in the uart schema, so it isn't asserted). +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "bl0940", + baud_rate=4800, + data_bits=8, + parity="NONE", + require_rx=True, + require_tx=True, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index c954b450330..4a75464b902 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -260,6 +260,11 @@ async def power_inv_to_code(var, config, args): pass +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "midea", baud_rate=9600, require_rx=True, require_tx=True +) + + async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 51b1ab2d80b..7e55fd9e7e2 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -58,6 +58,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "pzem004t", baud_rate=9600, require_rx=True, require_tx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index cbc54ad02b1..a65213d576b 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -29,6 +29,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rdm6300", baud_rate=9600, require_rx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9ca47fe8621..9863379b791 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -80,6 +80,16 @@ _CALLBACK_AUTOMATIONS = ( ), ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rf_bridge", + baud_rate=19200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index cec32e04064..549cce72dfd 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -195,10 +195,7 @@ void RFBridgeComponent::learn() { this->flush(); } -void RFBridgeComponent::dump_config() { - ESP_LOGCONFIG(TAG, "RF_Bridge:"); - this->check_uart_settings(19200); -} +void RFBridgeComponent::dump_config() { ESP_LOGCONFIG(TAG, "RF_Bridge:"); } void RFBridgeComponent::start_advanced_sniffing() { ESP_LOGI(TAG, "Advanced Sniffing on"); diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index b1f89f18bfa..1c222e5e803 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -73,7 +73,6 @@ void SDS011Component::dump_config() { this->update_interval_min_, ONOFF(this->rx_mode_only_)); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); - this->check_uart_settings(9600); } void SDS011Component::loop() { diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 76abc70bb70..2d7b6b07e55 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,6 +63,24 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + # In the default mode setup() writes config commands, so tx is required; + # rx_only mode never writes, so tx is optional. + uart.final_validate_device_schema( + "sds011", + baud_rate=9600, + require_rx=True, + require_tx=not config.get(CONF_RX_ONLY, False), + data_bits=8, + parity="NONE", + stop_bits=1, + )(config) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 8ed9fbb53b0..0e8e4cef97f 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -146,7 +146,6 @@ bool SenseAirComponent::senseair_write_command_(const uint8_t *command, uint8_t void SenseAirComponent::dump_config() { ESP_LOGCONFIG(TAG, "SenseAir:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::senseair diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index c5bef76741a..277648137a1 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -51,6 +51,16 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "senseair", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index ddf7fa161bd..f2ab5a4bc15 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -186,6 +186,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "shelly_dimmer", baud_rate=115200, require_rx=True, require_tx=True +) + async def to_code(config): fw_hex = get_firmware(config[CONF_FIRMWARE]) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 60c9ccc40d8..29e0cfe9b15 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -88,6 +88,10 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "sm300d2", baud_rate=9600, require_rx=True, data_bits=8, parity="NONE", stop_bits=1 +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 391cc0ac117..882959a4542 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -100,7 +100,6 @@ void SM300D2Sensor::dump_config() { LOG_SENSOR(" ", "PM10", this->pm_10_0_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::sm300d2 diff --git a/tests/components/bl0940/test.esp32-idf.yaml b/tests/components/bl0940/test.esp32-idf.yaml index 64baa4ec9d2..e74af834d46 100644 --- a/tests/components/bl0940/test.esp32-idf.yaml +++ b/tests/components/bl0940/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO14 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.esp8266-ard.yaml b/tests/components/bl0940/test.esp8266-ard.yaml index 89ca3ab5ae2..f614b0a3958 100644 --- a/tests/components/bl0940/test.esp8266-ard.yaml +++ b/tests/components/bl0940/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.rp2040-ard.yaml b/tests/components/bl0940/test.rp2040-ard.yaml index b28f2b5e05e..c8e2e3b55a6 100644 --- a/tests/components/bl0940/test.rp2040-ard.yaml +++ b/tests/components/bl0940/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp32-idf.yaml b/tests/components/rf_bridge/test.esp32-idf.yaml index 2d29656c94a..76222997a83 100644 --- a/tests/components/rf_bridge/test.esp32-idf.yaml +++ b/tests/components/rf_bridge/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp8266-ard.yaml b/tests/components/rf_bridge/test.esp8266-ard.yaml index 5a05efa259b..aaedec5aaac 100644 --- a/tests/components/rf_bridge/test.esp8266-ard.yaml +++ b/tests/components/rf_bridge/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.rp2040-ard.yaml b/tests/components/rf_bridge/test.rp2040-ard.yaml index f1df2daf83a..ed0cd431e3f 100644 --- a/tests/components/rf_bridge/test.rp2040-ard.yaml +++ b/tests/components/rf_bridge/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/rp2040-ard.yaml <<: !include common.yaml From 1d5490fd910b18565842e801f612b73de6bd60e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:38:34 -0400 Subject: [PATCH 169/343] [modbus] Only apply turnaround delay after broadcasts (#17209) --- esphome/components/modbus/modbus.cpp | 16 ++++-- esphome/components/modbus/modbus.h | 1 + .../fixtures/uart_mock_modbus_broadcast.yaml | 56 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 25 +++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 136fc73db6f..c9ba2e837e0 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -92,10 +92,14 @@ int32_t Modbus::tx_delay_remaining() { int32_t ModbusClientHub::tx_delay_remaining() { const uint32_t now = millis(); - return std::max({(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); + // Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices + // quiet time to process it before the next request. For normal unicast request/response the received reply already + // provides the inter-frame timing, so adding turnaround there just throttles throughput. + const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0; + return std::max( + {(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { @@ -396,6 +400,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; + this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0; return true; } @@ -411,7 +416,8 @@ void ModbusClientHub::send_next_frame_() { ModbusDeviceCommand &command = this->tx_buffer_.front(); if (this->send_frame_(command.frame)) { - this->waiting_for_response_ = std::move(command); + if (!this->last_send_was_broadcast_) + this->waiting_for_response_ = std::move(command); } else { if (command.device) command.device->on_modbus_not_sent(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 86337442c64..da0db13a074 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -63,6 +63,7 @@ class Modbus : public uart::UARTDevice, public Component { uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; + bool last_send_was_broadcast_{false}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml new file mode 100644 index 00000000000..a5ce02b3428 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml @@ -0,0 +1,56 @@ +esphome: + name: uart-mock-modbus-bcast + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No on_tx injection: a broadcast (address 0) gets no reply on a real bus. +uart_mock: + - id: virtual_uart + baud_rate: 9600 + auto_start: true + debug: + +modbus: + - uart_id: virtual_uart + id: virtual_modbus + role: client + send_wait_time: 200ms + turnaround_time: 10ms + +modbus_controller: + - address: 0 + modbus_id: virtual_modbus + update_interval: 60s + id: modbus_controller_bcast + +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_bcast + id: bcast_write + name: "bcast_write" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + +interval: + - interval: 400ms + then: + - number.set: + id: bcast_write + value: 42 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 2c437341c6c..385707d8492 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,3 +330,28 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that broadcast writes (address 0) don't wait for a response. + + A controller at address 0 sends broadcast writes that get no reply. The + client must not arm the response timeout for them: otherwise every write + blocks for send_wait_time and logs a spurious "no response from 0" warning. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected(), + ): + # Several broadcast writes fire on the 400ms interval; send_wait_time is + # 200ms, so the old behaviour would have warned on each one by now. + await asyncio.sleep(3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 6f36ce6429f690811e9d8b872eadc005cf521251 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 25 Jun 2026 12:35:15 -0400 Subject: [PATCH 170/343] [openthread] Provide action to control poll_period when device MTD (#11766) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- esphome/components/openthread/__init__.py | 37 ++++++++++- esphome/components/openthread/automation.cpp | 37 +++++++++++ esphome/components/openthread/automation.h | 61 +++++++++++++++++++ esphome/components/openthread/openthread.cpp | 29 +++++++++ esphome/components/openthread/openthread.h | 13 ++++ .../components/openthread/openthread_esp.cpp | 27 +------- tests/components/openthread/common.yaml | 2 + .../openthread/test-tlv.esp32-c6-idf.yaml | 20 ++++++ .../openthread/test.esp32-c6-idf.yaml | 13 +--- 9 files changed, 200 insertions(+), 39 deletions(-) create mode 100644 esphome/components/openthread/automation.cpp create mode 100644 esphome/components/openthread/automation.h create mode 100644 tests/components/openthread/common.yaml create mode 100644 tests/components/openthread/test-tlv.esp32-c6-idf.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2dc8a783dfd..b54fe2b2180 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( VARIANT_ESP32C5, @@ -226,11 +227,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FORCE_DATASET): cv.boolean, cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, - cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, _validate_txpower, ), + cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -309,3 +310,37 @@ async def to_code(config): ) zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) + + +# Actions +OpenThreadComponentPollPeriodAction = openthread_ns.class_( + "OpenThreadComponentPollPeriodAction", + automation.Action, + cg.Parented.template(OpenThreadComponent), +) + +POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( + CONF_POLL_PERIOD, + cv.Schema( + { + cv.GenerateID(): cv.use_id(OpenThreadComponent), + cv.Required(CONF_POLL_PERIOD): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), +) + + +@automation.register_action( + "openthread.set_poll_period", + OpenThreadComponentPollPeriodAction, + POLL_PERIOD_ACTION_SCHEMA, + synchronous=True, +) +async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) + cg.add(var.set_poll_period(template_)) + return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp new file mode 100644 index 00000000000..770bf124c55 --- /dev/null +++ b/esphome/components/openthread/automation.cpp @@ -0,0 +1,37 @@ +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD + +#include "automation.h" +#include "esphome/core/log.h" + +namespace esphome::openthread { + +static const char *const TAG = "openthread.automation"; + +void OpenThreadComponentBaseAction::warn_ftd_no_op_() { + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +} + +void OpenThreadComponentBaseAction::lock_and_apply_() { + if (this->parent_->is_ready()) { + if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { + if (auto *instance = lock.get_instance(); instance != nullptr) { + this->apply_locked(instance); + } + } else { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + } + } else { + // Action may trigger early before setup, e.g. due to enabled "restore mode". + // Trying to acquire lock would fail! + // + // But default component values already have been overwritten. + // It is sufficient to let component apply those later during setup. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + } +} + +} // namespace esphome::openthread + +#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h new file mode 100644 index 00000000000..3706499fda0 --- /dev/null +++ b/esphome/components/openthread/automation.h @@ -0,0 +1,61 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_OPENTHREAD +#include "openthread.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::openthread { + +/** Base class allowing to fetch OpenThread lock from parent component + * while applying action + * + * - Nontemplate aspects belong here to avoid template bloat. + * - Subclasses implement virtual action method that is called under lock. + * - Seal leaf subclasses via @a final to support devirtualization. + */ +class OpenThreadComponentBaseAction : public Parented { + public: + // Enforce ctor with parent argument (not without args) + explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} + + protected: + /** Handler to implement in subclass for applying action parts that need lock */ + virtual void apply_locked(otInstance *instance) = 0; + + /** Fetch OT lock and then call @a apply_locked */ + void lock_and_apply_(); + + /** Log a warning that this action has no effect on FTD devices */ + void warn_ftd_no_op_(); + + /** Timeout (ms) for acquiring OT lock */ + static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; +}; + +/** Action to set single poll period parameter */ +template +class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { + TEMPLATABLE_VALUE(uint32_t, poll_period) + + public: + /* Passthrough ctor */ + using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; + + protected: + void play(const Ts &...x) override { +#if CONFIG_OPENTHREAD_MTD + this->parent_->set_poll_period(this->poll_period_.value(x...)); + + this->lock_and_apply_(); +#else + this->warn_ftd_no_op_(); +#endif + } + + void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } +}; + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 102424c62e0..8bfc16b2e05 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -266,5 +266,34 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_linkmode_(otInstance *instance) { + otLinkModeConfig link_mode_config{}; +#if CONFIG_OPENTHREAD_FTD + link_mode_config.mRxOnWhenIdle = true; + link_mode_config.mDeviceType = true; + link_mode_config.mNetworkData = true; +#elif CONFIG_OPENTHREAD_MTD + if (this->poll_period_ > 0) { + if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set pollperiod"); + } + ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); + } + link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; + link_mode_config.mDeviceType = false; + link_mode_config.mNetworkData = false; +#endif + + if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set linkmode"); + } +#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG + link_mode_config = otThreadGetLinkMode(instance); + ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", + TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), + TRUEFALSE(link_mode_config.mRxOnWhenIdle)); +#endif +} + } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 488aad11662..a96941325cf 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,6 +19,8 @@ namespace esphome::openthread { class InstanceLock; +template class OpenThreadComponentPollPeriodAction; + class OpenThreadComponent final : public Component { public: OpenThreadComponent(); @@ -41,12 +43,23 @@ class OpenThreadComponent final : public Component { void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } + uint32_t get_poll_period() const { return this->poll_period_; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); protected: + // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() + // without exposing this lock-sensitive, raw-instance method on the public API. + template friend class OpenThreadComponentPollPeriodAction; + + /** Apply Link Mode settings (incl poll period). + * Callers running outside the OpenThread task must hold InstanceLock. + * ot_main() runs on the OpenThread task itself and must not acquire the lock. + */ + void apply_linkmode_(otInstance *instance); + std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 6edaa98524c..4f6e618f491 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -111,32 +111,7 @@ void OpenThreadComponent::ot_main() { ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - otLinkModeConfig link_mode_config{}; -#if CONFIG_OPENTHREAD_FTD - link_mode_config.mRxOnWhenIdle = true; - link_mode_config.mDeviceType = true; - link_mode_config.mNetworkData = true; -#elif CONFIG_OPENTHREAD_MTD - if (this->poll_period_ > 0) { - if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set pollperiod"); - } - ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); - } - link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; - link_mode_config.mDeviceType = false; - link_mode_config.mNetworkData = false; -#endif - - if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set linkmode"); - } -#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG - link_mode_config = otThreadGetLinkMode(instance); - ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", - TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), - TRUEFALSE(link_mode_config.mRxOnWhenIdle)); -#endif + this->apply_linkmode_(instance); if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { diff --git a/tests/components/openthread/common.yaml b/tests/components/openthread/common.yaml new file mode 100644 index 00000000000..d9eeab89ea1 --- /dev/null +++ b/tests/components/openthread/common.yaml @@ -0,0 +1,2 @@ +network: + enable_ipv6: true diff --git a/tests/components/openthread/test-tlv.esp32-c6-idf.yaml b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml new file mode 100644 index 00000000000..c61efd4d3cf --- /dev/null +++ b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +<<: !include common.yaml + +openthread: + device_type: MTD + force_dataset: false + use_address: open-thread-test.local + tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8 + poll_period: 5s + +switch: + - platform: template + name: "Radio Always On" + optimistic: true + restore_mode: ALWAYS_OFF + turn_on_action: + then: + - openthread.set_poll_period: 0s + turn_off_action: + then: + - openthread.set_poll_period: 5s diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 008edd53972..92d120e5d19 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -1,14 +1,6 @@ -esp32: - board: esp32-c6-devkitc-1 - framework: - type: esp-idf - log_level: DEBUG - -network: - enable_ipv6: true +<<: !include common.yaml openthread: - device_type: MTD channel: 13 network_name: OpenThread-8f28 network_key: 0xdfd34f0f05cad978ec4e32b0413038ff @@ -16,7 +8,4 @@ openthread: ext_pan_id: 0xd63e8e3e495ebbc3 pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 - force_dataset: true - use_address: open-thread-test.local - poll_period: 20sec output_power: 1dBm From e27390bddb87508ad04595055e328a7c1bead5b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:36:10 -0400 Subject: [PATCH 171/343] [hbridge] Fix light stuck on one polarity (#17162) --- esphome/components/hbridge/light/__init__.py | 6 ++-- .../hbridge/light/hbridge_light_output.h | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index ccb47237b64..f9451e25949 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -1,14 +1,14 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B +from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.Component, light.LightOutput + "HBridgeLightOutput", cg.PollingComponent, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( @@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput), cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput), cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput), + cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval, } ) async def to_code(config): var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index c0107fdc0d6..9dcf7adfd8c 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -3,11 +3,10 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -#include "esphome/core/helpers.h" namespace esphome::hbridge { -class HBridgeLightOutput final : public Component, public light::LightOutput { +class HBridgeLightOutput final : public PollingComponent, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } @@ -20,11 +19,12 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { return traits; } - void setup() override { this->disable_loop(); } + void setup() override { this->stop_poller(); } - void loop() override { - // Only called when both channels are active — alternate H-bridge direction - // each iteration to multiplex cold and warm white. + void update() override { + // Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay + // slower than the output's PWM period (flipping faster collapses the output onto one + // channel) but fast enough to avoid flicker (issue #17030). if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); @@ -46,13 +46,17 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { this->pinb_duty_ = new_pinb; if (new_pina != 0.0f && new_pinb != 0.0f) { - // Both channels active — need loop to alternate H-bridge direction - this->high_freq_.start(); - this->enable_loop(); + // Both channels active — multiplex the H-bridge direction via the poller. + if (!this->multiplexing_) { + this->multiplexing_ = true; + this->start_poller(); + } } else { - // Zero or one channel active — drive pins directly, no multiplexing needed - this->high_freq_.stop(); - this->disable_loop(); + // Zero or one channel active — drive pins directly, no multiplexing needed. + if (this->multiplexing_) { + this->multiplexing_ = false; + this->stop_poller(); + } this->pina_pin_->set_level(new_pina); this->pinb_pin_->set_level(new_pinb); } @@ -64,7 +68,7 @@ class HBridgeLightOutput final : public Component, public light::LightOutput { float pina_duty_{0}; float pinb_duty_{0}; bool forward_direction_{false}; - HighFrequencyLoopRequester high_freq_; + bool multiplexing_{false}; }; } // namespace esphome::hbridge From e304c318fb75d168dff3de74c394ddf80e5f4cdb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:47:24 +0200 Subject: [PATCH 172/343] Bump bundled esphome-device-builder to 1.0.18 (#17212) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c02aba093cd..8159f1d32e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.17 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 RUN \ platformio settings set enable_telemetry No \ From ddf075a2dd399c22f450f1fbb921c111442f77c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:00:52 +0000 Subject: [PATCH 173/343] Bump aioesphomeapi from 45.3.1 to 45.5.2 (#17211) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 462438016e7..956f3633dcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.0 click==8.3.3 -aioesphomeapi==45.3.1 +aioesphomeapi==45.5.2 zeroconf==0.150.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From cc646b22135d2cbc72a76045543f5496e4ba6b24 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Jun 2026 21:34:33 +0200 Subject: [PATCH 174/343] [core] Defer requests import in framework_helpers to speed up config validation (#17215) --- esphome/framework_helpers.py | 6 ++-- tests/unit_tests/test_framework_helpers.py | 37 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6bf389240b0..a8e5cf75a85 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -11,8 +11,6 @@ import sys import time from typing import IO -import requests - from esphome.helpers import ProgressBar, rmtree PathType = str | os.PathLike @@ -635,6 +633,10 @@ def download_from_mirrors( ValueError: If mirrors list is empty. Exception: If all download attempts fail. """ + # Imported lazily: requests is a heavy import (~85ms) and is only needed + # when actually downloading a toolchain, never during config validation. + import requests + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index f6e783b5e82..fd807ed05d2 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -526,7 +526,7 @@ class TestDownloadFromMirrors: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: target = tmp_path / "out.bin" with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"filedata"), ): url = download_from_mirrors(["https://example.com/f"], {}, target) @@ -535,7 +535,7 @@ class TestDownloadFromMirrors: def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"x"), ) as mock_get: download_from_mirrors( @@ -547,7 +547,7 @@ class TestDownloadFromMirrors: def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], ): url = download_from_mirrors( @@ -561,7 +561,7 @@ class TestDownloadFromMirrors: def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: with ( patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"", ok=False), ), pytest.raises(req.HTTPError), @@ -579,7 +579,7 @@ class TestDownloadFromMirrors: def test_file_like_target_written(self) -> None: buf = io.BytesIO() with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"bytes"), ): download_from_mirrors(["https://example.com/f"], {}, buf) @@ -590,7 +590,7 @@ class TestDownloadFromMirrors: r = _mock_response(b"1234567890") r.headers = {"content-length": "10"} with ( - patch("esphome.framework_helpers.requests.get", return_value=r), + patch("requests.get", return_value=r), patch("esphome.framework_helpers.ProgressBar") as mock_pb, ): download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") @@ -606,12 +606,35 @@ class TestDownloadFromMirrors: r.headers = {"content-length": "0"} r.iter_content.return_value = [b""] # one empty chunk target = tmp_path / "out.bin" - with patch("esphome.framework_helpers.requests.get", return_value=r): + with patch("requests.get", return_value=r): download_from_mirrors(["https://example.com/f"], {}, target) assert target.exists() assert target.read_bytes() == b"" +def test_importing_framework_helpers_does_not_import_requests() -> None: + """Importing framework_helpers must not drag in requests. + + requests is a heavy import (~85ms) only needed by download_from_mirrors to + fetch toolchains during a build. framework_helpers is loaded during config + validation (esp-idf framework, host platform), so the import is deferred to + the function that uses it. A fresh interpreter is required because the test + process has already imported requests. + """ + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\nimport esphome.framework_helpers\n" + "print('\\n'.join(sys.modules))", + ], + capture_output=True, + text=True, + check=True, + ) + assert "requests" not in result.stdout.split() + + # --------------------------------------------------------------------------- # get_python_env_executable_path — Windows branch # --------------------------------------------------------------------------- From 239211e5210dd2eb111c30e9520201e241908be7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Jun 2026 21:34:44 +0200 Subject: [PATCH 175/343] [time] Defer aioesphomeapi import to speed up config validation (#17214) --- esphome/components/time/__init__.py | 32 +++++++++++------- tests/unit_tests/components/test_time.py | 42 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index b3bf2d44d75..35fad0a4509 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,11 +1,8 @@ import errno +import functools from importlib import resources import logging -from aioesphomeapi.posix_tz import ( - DSTRuleType as PyDSTRuleType, - parse_posix_tz as parse_posix_tz_python, -) import tzlocal from esphome import automation @@ -57,13 +54,20 @@ DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) DSTRule_cpp = time_ns.struct("DSTRule") ParsedTimezone_cpp = time_ns.struct("ParsedTimezone") -# Map Python DSTRuleType enum values to C++ enum expressions -_DST_RULE_TYPE_MAP = { - PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, - PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, - PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, - PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, -} + +# Map Python DSTRuleType enum values to C++ enum expressions. Built lazily to +# avoid importing aioesphomeapi (a heavy import) when the time component is only +# auto-loaded for its schema and never reaches code generation. +@functools.cache +def _dst_rule_type_map() -> dict: + from aioesphomeapi.posix_tz import DSTRuleType as PyDSTRuleType + + return { + PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, + PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, + PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, + PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, + } def _load_tzdata(iana_key: str) -> bytes | None: @@ -317,6 +321,8 @@ def validate_tz(value: str) -> str: # Validate that the POSIX TZ string is parseable (skip empty strings) if value: + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parse_posix_tz_python(value) except ValueError as e: @@ -372,7 +378,7 @@ def _emit_dst_rule_fields(prefix, rule): """Emit field-by-field assignments for a DSTRule to avoid rodata struct blob.""" cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}")) cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}")) - cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}")) + cg.add(cg.RawExpression(f"{prefix}.type = {_dst_rule_type_map()[rule.type]}")) cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}")) cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}")) cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}")) @@ -409,6 +415,8 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parsed = parse_posix_tz_python(timezone) _emit_parsed_timezone_fields(parsed) diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 5ae9d787d6e..6f3b4bb14f7 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,8 @@ """Tests for time component cron expression parsing.""" import errno +import subprocess +import sys from unittest.mock import MagicMock, patch import pytest @@ -143,3 +145,43 @@ def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> Non _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), ): assert validate_tz("<+08>-8") == "<+08>-8" + + +def _modules_after(code: str) -> set[str]: + """Run code in a fresh interpreter and return the imported module names. + + A subprocess is required because the test process itself has already + imported aioesphomeapi via other tests, so sys.modules here is useless. + """ + result = subprocess.run( + [sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"], + capture_output=True, + text=True, + check=True, + ) + return set(result.stdout.split()) + + +def test_importing_time_does_not_import_aioesphomeapi() -> None: + """Importing the time component must not drag in aioesphomeapi. + + aioesphomeapi is a heavy import (it builds a large number of dataclasses at + import time). The time component is auto-loaded by many components, so + importing it for its schema during config validation must not pay that + cost. The import is deferred to the functions that actually need it. + """ + modules = _modules_after("import esphome.components.time") + assert "aioesphomeapi" not in modules + + +def test_validate_tz_imports_aioesphomeapi_lazily() -> None: + """Validating a non-empty timezone is what triggers the lazy import. + + Documents the boundary: the cost is only paid when a timezone is actually + validated, not merely by loading the component. + """ + modules = _modules_after( + "from esphome.components.time import validate_tz\n" + "validate_tz('EST5EDT,M3.2.0,M11.1.0')" + ) + assert "aioesphomeapi" in modules From f49bed47de91fdac0954e714970aa8a530a98511 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:07:55 -0400 Subject: [PATCH 176/343] Bump ruff from 0.15.19 to 0.15.20 (#17216) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 6e53a4c14fa..ebd93ea390a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.19 # also change in .pre-commit-config.yaml when updating +ruff==0.15.20 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From be8523a73c30efe3df499c5f968742f3e942f55a Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:33:38 +0100 Subject: [PATCH 177/343] [mdns] Add mDNS to Zephyr and nRF52 (#16924) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mdns/mdns_component.cpp | 5 ++++- esphome/components/mdns/mdns_zephyr.cpp | 13 ++++++++++--- tests/components/mdns/test.nrf52-adafruit.yaml | 4 ++++ 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 tests/components/mdns/test.nrf52-adafruit.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9bf27e71e4c..e11cb1abaa1 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -100,7 +100,7 @@ void MDNSComponent::compile_records_(StaticVector &) {} -void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_zephyr); } +#else +// No responder and nothing consuming the records, so skip the boot-time compile. +void MDNSComponent::setup() {} +#endif void MDNSComponent::on_shutdown() {} diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..6aff688ff49 --- /dev/null +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +network: + enable_ipv6: true + +mdns: From f9f28a6a007a99ad2594204dc45c2c5a3fb4e30e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:48:52 +0200 Subject: [PATCH 178/343] Bump bundled esphome-device-builder to 1.0.19 (#17217) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8159f1d32e6..66dad179bbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.18 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 RUN \ platformio settings set enable_telemetry No \ From 75cdabee3d59cca25a788bb28873533130f41a4e Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:30:07 +0100 Subject: [PATCH 179/343] [socket] Add BSD socket support for nRF52 (#16699) Co-authored-by: tomaszduda23 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/socket/__init__.py | 6 +++ .../components/socket/bsd_sockets_impl.cpp | 6 ++- esphome/components/socket/bsd_sockets_impl.h | 28 +++++++++- esphome/components/socket/headers.h | 6 +++ esphome/components/socket/socket.cpp | 52 ++++++++++++++++++- esphome/components/socket/socket.h | 4 +- .../socket/test.nrf52-adafruit.yaml | 1 + .../components/socket/test.nrf52-mcumgr.yaml | 1 + .../socket/test.nrf52-xiao-ble.yaml | 1 + 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 tests/components/socket/test.nrf52-adafruit.yaml create mode 100644 tests/components/socket/test.nrf52-mcumgr.yaml create mode 100644 tests/components/socket/test.nrf52-xiao-ble.yaml diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index abbbb0f056f..38d787c20a5 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -149,6 +149,7 @@ CONFIG_SCHEMA = cv.Schema( ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, host=IMPLEMENTATION_BSD_SOCKETS, + nrf52=IMPLEMENTATION_BSD_SOCKETS, ): cv.one_of( IMPLEMENTATION_LWIP_TCP, IMPLEMENTATION_LWIP_SOCKETS, @@ -168,6 +169,11 @@ async def to_code(config): cg.add_define("USE_SOCKET_IMPL_LWIP_SOCKETS") elif impl == IMPLEMENTATION_BSD_SOCKETS: cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + if CORE.using_zephyr: + from esphome.components.zephyr import zephyr_add_prj_conf + + zephyr_add_prj_conf("NET_SOCKETS", True) + zephyr_add_prj_conf("POSIX_API", True) # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() # and FreeRTOS task notifications — enable fast select to bypass lwip_select(). # Only when not using lwip_tcp, which does not provide select() support. diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index ee22e4b97b2..0d4284f1456 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -22,11 +22,13 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (flags >= 0) ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); #endif + // Guard structure matches socket_ready_fd(): non-HOST platforms (nRF52/OpenThread) + // do not register fds with the esphome select loop, so monitor_loop is a no-op there. if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); -#else +#elif defined(USE_HOST) this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -45,7 +47,7 @@ int BSDSocketImpl::close() { // touch an unrelated socket's pcb. No per-socket callback unhook is needed — // all LwIP sockets share the same static event_callback. this->cached_sock_ = nullptr; -#else +#elif defined(USE_HOST) if (this->loop_monitored_) { wake_unregister_fd(this->fd_); } diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 57c1a430a2b..1b5ea9ebcd6 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -76,7 +76,7 @@ class BSDSocketImpl { #endif } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) { -#if defined(USE_ESP32) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) return ::recvfrom(this->fd_, buf, len, 0, addr, addr_len); #else return ::lwip_recvfrom(this->fd_, buf, len, 0, addr, addr_len); @@ -85,6 +85,19 @@ class BSDSocketImpl { ssize_t readv(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_readv(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide readv(); emulate with a read() loop. Stream sockets only: + // on a datagram socket each read() would consume a separate datagram, not scatter one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::read(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; + } + return total; #else return ::readv(this->fd_, iov, iovcnt); #endif @@ -100,6 +113,19 @@ class BSDSocketImpl { ssize_t writev(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_writev(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide writev(); emulate with a write() loop. Stream sockets only: + // on a datagram socket each write() would emit a separate datagram, not gather one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::write(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; // partial write: stop so caller resumes from the correct stream offset + } + return total; #else return ::writev(this->fd_, iov, iovcnt); #endif diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 0eece6480f6..f9b652f14a7 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -158,7 +158,9 @@ using socklen_t = uint32_t; #include #include #include +#ifndef USE_ZEPHYR #include +#endif #include #ifdef USE_HOST @@ -167,6 +169,10 @@ using socklen_t = uint32_t; #include #include #endif // USE_HOST +#ifdef USE_ZEPHYR +#include +#include +#endif // USE_ZEPHYR #ifdef USE_ARDUINO // arduino-esp32 declares a global var called INADDR_NONE which is replaced diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f14ac1e2d58..212da80312b 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -12,9 +12,19 @@ namespace esphome::socket { #ifdef USE_HOST -// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the host wake select() loop has marked this fd as ready. +// Host: ready when the wake select() loop has flagged this fd (or it isn't monitored). bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } +#elif defined(USE_ZEPHYR) +// Zephyr (nRF52): fd monitoring isn't wired into the esphome select loop +// (wake_register_fd is USE_HOST-only), so loop_monitored is always false. Always +// return true — the caller handles EAGAIN/EWOULDBLOCK on read. +// +// Cost (known trade-off, not an oversight): loop-monitored sockets (API, web_server) +// are read every loop() iteration and bail on EAGAIN; there is no event-driven wake, +// so the main loop busy-polls at loop frequency and cannot idle between packets. +// TODO: wire Zephyr fds into an event-driven wake source (e.g. zsock_poll/k_poll) so +// the loop can sleep between packets on battery/OpenThread targets. +bool socket_ready_fd(int /*fd*/, bool /*loop_monitored*/) { return true; } #endif // Platform-specific inet_ntop wrappers @@ -40,6 +50,19 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t return lwip_inet_ntop(AF_INET6, addr, buf, size); } #endif +#elif defined(USE_ZEPHYR) +// Zephyr BSD sockets — use Zephyr native address formatting via POSIX-subset wrappers. +// is already included transitively through . +static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET, addr, buf, size); +} +// IPv6 is always enabled on nRF52 (config validation enforces enable_ipv6=True), +// but the guard is retained for consistency with other platform blocks. +#if USE_NETWORK_IPV6 +static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET6, addr, buf, size); +} +#endif #else // BSD sockets (host, ESP32-IDF) static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { @@ -68,6 +91,15 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { return strlen(buf.data()); } +#elif defined(USE_ZEPHYR) + // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX + // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so + // detect the ::ffff:0:0/96 prefix directly on the address words. + if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && + addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && + esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { + return strlen(buf.data()); + } #elif !defined(USE_SOCKET_IMPL_LWIP_TCP) // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && @@ -117,11 +149,19 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ server->sin6_port = htons(port); #ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { + errno = EINVAL; + return 0; + } +#else // Use standard inet_pton for BSD sockets if (inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { errno = EINVAL; return 0; } +#endif #else // Use LWIP-specific functions ip6_addr_t ip6; @@ -138,7 +178,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ auto *server = reinterpret_cast(addr); memset(server, 0, sizeof(sockaddr_in)); server->sin_family = AF_INET; +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET, ip_address, &server->sin_addr) != 1) { + errno = EINVAL; + return 0; + } +#else server->sin_addr.s_addr = inet_addr(ip_address); +#endif server->sin_port = htons(port); return sizeof(sockaddr_in); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 204113e4b25..eb8870786d0 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -60,11 +60,11 @@ inline struct lwip_sock *hook_fd_for_fast_select(int fd) { } return sock; } -#elif defined(USE_HOST) +#elif defined(USE_HOST) || defined(USE_ZEPHYR) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); -#endif +#endif // USE_LWIP_FAST_SELECT // Inline ready() — defined here because it depends on socket_ready/socket_ready_fd // declared above, while the impl headers are included before those declarations. diff --git a/tests/components/socket/test.nrf52-adafruit.yaml b/tests/components/socket/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..d55dfd1557b --- /dev/null +++ b/tests/components/socket/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-mcumgr.yaml b/tests/components/socket/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..d55dfd1557b --- /dev/null +++ b/tests/components/socket/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-xiao-ble.yaml b/tests/components/socket/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..d55dfd1557b --- /dev/null +++ b/tests/components/socket/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +socket: From da5e11d1966cc26bbe9c2a614914d7a8c86f9e39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 15:49:56 +0200 Subject: [PATCH 180/343] [core] Fix area saved as null in storage.json (#17219) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/core/config.py | 12 +++++++++++- tests/unit_tests/core/test_config.py | 29 +++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index b925f0b7d96..59c96035b8f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str: CORE.name = conf[CONF_NAME] CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME) + # Record the node's area name now (substitutions are already resolved at this + # point). storage.json is written before to_code() runs, so deferring this to + # to_code() left the area as null in storage.json. The value here is the raw + # post-substitution form (a plain string or a {name: ...} mapping). Assign + # unconditionally (like friendly_name) so a config without an area never + # inherits a stale value from a previous load in a long-running process, and + # use .get() so a malformed mapping surfaces later as a proper validation + # error rather than a KeyError here. to_code() sets it again from the + # validated config, which yields the same name. + area = conf.get(CONF_AREA) + CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: @@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e2b34d92d82..b3d87f68577 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: ("multiple_areas_devices.yaml", "Main Area"), ], ) -async def test_to_code_records_core_area( +async def test_core_area_recorded_at_config_load( yaml_file: Callable[[str], Path], fixture: str, expected_area: str, ) -> None: - """``to_code`` records the node's area name on CORE for StorageJSON.""" + """The node's area name is recorded on CORE for StorageJSON. + + It must be set during config load (preload_core_config), not deferred to + to_code(): storage.json is written before to_code() runs, so a late + assignment left the area as null in storage.json (regression #17218). + """ result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) assert result is not None - assert CORE.area is None + # Recorded already at config-load time, before any code generation. + assert CORE.area == expected_area with patch("esphome.core.config.cg") as mock_cg: mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() @@ -170,6 +176,23 @@ async def test_to_code_records_core_area( assert CORE.area == expected_area +def test_config_load_without_area_clears_stale_core_area( + yaml_file: Callable[[str], Path], +) -> None: + """A config without an area must not inherit a stale CORE.area. + + preload_core_config assigns CORE.area unconditionally, so the area from a + previous load in a long-running process cannot leak into a config that + omits it. + """ + CORE.area = "Stale Area From Previous Load" + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) + assert result is not None + assert CORE.area is None + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: From 7811781a9608898774d34b35265257b84044790a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 16:21:36 +0200 Subject: [PATCH 181/343] [es8388] Fix DAC unable to unmute once muted (#17221) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/es8388/es8388.cpp | 8 +++++++- esphome/components/es8388/es8388_const.h | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c015393e146..0b972402308 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) { ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value)); ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value); + // Only toggle the DACMute bit; the other bits of this register hold unrelated + // DAC settings that must be preserved. Previously muting overwrote the whole + // register with 0x3C and unmuting never cleared the bit, so once muted the DAC + // could not be unmuted again. if (mute_state) { - value = 0x3C; + value |= ES8388_DACCONTROL3_DAC_MUTE; + } else { + value &= ~ES8388_DACCONTROL3_DAC_MUTE; } ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state)); diff --git a/esphome/components/es8388/es8388_const.h b/esphome/components/es8388/es8388_const.h index 451c9cc0266..e081c55dbd2 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16; static const uint8_t ES8388_DACCONTROL1 = 0x17; static const uint8_t ES8388_DACCONTROL2 = 0x18; static const uint8_t ES8388_DACCONTROL3 = 0x19; +static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3 static const uint8_t ES8388_DACCONTROL4 = 0x1a; static const uint8_t ES8388_DACCONTROL5 = 0x1b; static const uint8_t ES8388_DACCONTROL6 = 0x1c; From 88875daf52f3e72daf1a467e4e093547e80ab236 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:32:39 -0400 Subject: [PATCH 182/343] Bump actions/cache/restore from 6.0.0 to 6.1.0 in /.github/actions/restore-python (#17228) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6290e25d7c7..1364e956026 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length From 7ad4cbf46fc5d9df6feb7d2d3b8d9c01f3f6544d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:32:50 -0400 Subject: [PATCH 183/343] Bump actions/cache/save from 6.0.0 to 6.1.0 (#17229) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaa04ceca6f..8700060198f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,7 +250,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -339,7 +339,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -1164,7 +1164,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} From 063c4371dee33c594cb311d448654029a15d06c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:33:01 -0400 Subject: [PATCH 184/343] Bump actions/cache from 6.0.0 to 6.1.0 (#17230) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8700060198f..7a4c1ebc239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -365,7 +365,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -509,7 +509,7 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} From 436938b931771ab726afbfc476bc494da05cf26f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:33:13 -0400 Subject: [PATCH 185/343] Bump actions/cache/restore from 6.0.0 to 6.1.0 (#17231) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a4c1ebc239..72519e421ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,7 +295,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -516,7 +516,7 @@ jobs: - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -1098,7 +1098,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1122,7 +1122,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1211,7 +1211,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 24ec65e68eb56b5e56a0bc007047af2ce7a3a034 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 27 Jun 2026 17:26:47 +0200 Subject: [PATCH 186/343] [esp32] Only warn about S3 PSRAM pins (GPIO33-37) in octal mode (#17222) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 4 ++ esphome/components/esp32/gpio.py | 16 ++++++- esphome/components/esp32/gpio_esp32_s3.py | 45 ++++++++++++++++--- .../config/psram_octal_disabled_gpio34.yaml | 16 +++++++ .../esp32/config/psram_octal_gpio34.yaml | 15 +++++++ .../esp32/config/psram_quad_gpio34.yaml | 15 +++++++ tests/component_tests/esp32/test_esp32.py | 26 +++++++++++ 7 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml create mode 100644 tests/component_tests/esp32/config/psram_octal_gpio34.yaml create mode 100644 tests/component_tests/esp32/config/psram_quad_gpio34.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 945eda3912e..a5528da6727 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1102,6 +1102,8 @@ def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + from .gpio import final_validate_pins + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] @@ -1185,6 +1187,8 @@ def final_validate(config): ) ) + final_validate_pins(full_config) + if ( config[CONF_FLASH_SIZE] == "32MB" and "ota" in full_config diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 2ff39cab696..321dd3d498f 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -18,6 +18,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core import CORE +from esphome.types import ConfigType from . import boards from .const import ( @@ -50,7 +51,11 @@ from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_support from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports -from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s3 import ( + esp32_s3_final_validate_pins, + esp32_s3_validate_gpio_pin, + esp32_s3_validate_supports, +) from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -96,6 +101,7 @@ def _translate_pin(value): class ESP32ValidationFunctions: pin_validation: Callable[[int], int] usage_validation: Callable[[dict[str, Any]], dict[str, Any]] + final_validate: Callable[[ConfigType], None] | None = None _esp32_validations = { @@ -145,6 +151,7 @@ _esp32_validations = { VARIANT_ESP32S3: ESP32ValidationFunctions( pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, + final_validate=esp32_s3_final_validate_pins, ), VARIANT_ESP32S31: ESP32ValidationFunctions( pin_validation=esp32_s31_validate_gpio_pin, @@ -261,3 +268,10 @@ async def esp32_pin_to_code(config): cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var + + +def final_validate_pins(full_config: ConfigType) -> None: + """Run the active variant's pin final-validation, if it defines one.""" + funcs = _esp32_validations.get(CORE.data[KEY_ESP32][KEY_VARIANT]) + if funcs is not None and funcs.final_validate is not None: + funcs.final_validate(full_config) diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index f528de4ccde..db8c5205336 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -2,8 +2,15 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER -from esphome.pins import check_strapping_pin +from esphome.const import ( + CONF_DISABLED, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + PLATFORM_ESP32, +) +from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin +from esphome.types import ConfigType _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", @@ -38,11 +45,9 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-S3s and is already used by the SPI/PSRAM interface(function: {_ESP32S3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32S3R8_PSRAM_PINS: - _LOGGER.warning( - "GPIO%d is used by the PSRAM interface on ESP32-S3R8 / ESP32-S3R8V and should be avoided on these models", - value, - ) + # GPIO33-37 (_ESP32S3R8_PSRAM_PINS) are only taken by the PSRAM interface in + # octal mode -- whether that applies isn't known here, so the warning is + # deferred to final_validate_pins() in gpio.py once the PSRAM mode is resolved. if value in (22, 23, 24, 25): # These pins are not exposed in GPIO mux (reason unknown) @@ -71,3 +76,29 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s3_final_validate_pins(full_config: ConfigType) -> None: + """Warn about GPIO33-37 usage, but only when octal PSRAM (which uses them) is set. + + These pins are only taken by the PSRAM interface in octal mode (ESP32-S3R8 / + S3R8V); on quad-PSRAM variants -- or when the psram block is disabled, so the + octal interface is never configured -- they are free. The per-pin validator + can't know the PSRAM mode, so the check is deferred here, where + PIN_SCHEMA_REGISTRY.pins_used already lists every used pin. + """ + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN, TYPE_OCTAL + + psram_config = full_config.get(PSRAM_DOMAIN, {}) + if psram_config.get(CONF_DISABLED) or psram_config.get(CONF_MODE) != TYPE_OCTAL: + return + for number in sorted( + number + for key, _client_id, number in PIN_SCHEMA_REGISTRY.pins_used + if key == PLATFORM_ESP32 and number in _ESP32S3R8_PSRAM_PINS + ): + _LOGGER.warning( + "GPIO%d is used by the PSRAM interface in octal mode and should be avoided", + number, + ) diff --git a/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml new file mode 100644 index 00000000000..450e1bb345e --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + disabled: true + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_octal_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml new file mode 100644 index 00000000000..b385057d795 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_quad_gpio34.yaml b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml new file mode 100644 index 00000000000..9612edb75b2 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: quad + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bdba981c44d..cea34bef7cf 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -213,6 +213,32 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +@pytest.mark.parametrize( + ("fixture", "expect_warning"), + [ + ("psram_quad_gpio34.yaml", False), + ("psram_octal_gpio34.yaml", True), + ("psram_octal_disabled_gpio34.yaml", False), + ], +) +def test_s3_psram_pin_warning_only_for_octal( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, + fixture: str, + expect_warning: bool, +) -> None: + """GPIO33-37 are only used by the PSRAM interface in octal mode. + + Using such a pin must only warn when octal PSRAM is configured; on quad + PSRAM the pins are free and warning would be a false positive (#16857). + """ + with caplog.at_level("WARNING"): + generate_main(component_config_path(fixture)) + warned = "GPIO34 is used by the PSRAM interface in octal mode" in caplog.text + assert warned == expect_warning + + def test_ignore_pin_validation_error_on_clean_pin_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, From ccc57475b76a928b0aaaefbc95019a405b0fac1f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:04:45 -0400 Subject: [PATCH 187/343] [deep_sleep] Add ESP32-C5 support (#17237) --- .../deep_sleep/deep_sleep_component.h | 3 ++- .../components/deep_sleep/deep_sleep_esp32.cpp | 17 ++++++++++------- .../deep_sleep/test.esp32-c5-idf.yaml | 5 +++++ .../deep_sleep/test.esp32-c61-idf.yaml | 5 +++++ 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 tests/components/deep_sleep/test.esp32-c5-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-c61-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 8edda040d3a..896ed092aa7 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -96,7 +96,8 @@ class DeepSleepComponent final : public Component { #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void set_touch_wakeup(bool touch_wakeup); #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c905b8fcbca..7cb8e53efd4 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::deep_sleep { // | ESP32-S3 | ✓ | ✓ | ✓ | | // | ESP32-C2 | | | | ✓ | // | ESP32-C3 | | | | ✓ | -// | ESP32-C5 | | (✓) | | (✓) | +// | ESP32-C5 | | ✓ | | ✓ | // | ESP32-C6 | | ✓ | | ✓ | // | ESP32-C61 | | ✓ | | ✓ | // | ESP32-H2 | | ✓ | | | @@ -56,7 +56,8 @@ void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wa #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif @@ -99,7 +100,8 @@ void DeepSleepComponent::deep_sleep_() { // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -122,9 +124,9 @@ void DeepSleepComponent::deep_sleep_() { } #endif - // GPIO wakeup - C2, C3, C6, C61 only -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) + // GPIO wakeup - C2, C3, C5, C6, C61 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); // Make sure GPIO is in input mode, not all RTC GPIO pins are input by default @@ -154,7 +156,8 @@ void DeepSleepComponent::deep_sleep_() { // Touch wakeup - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { esp_sleep_enable_touchpad_wakeup(); esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); diff --git a/tests/components/deep_sleep/test.esp32-c5-idf.yaml b/tests/components/deep_sleep/test.esp32-c5-idf.yaml new file mode 100644 index 00000000000..11abe707116 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c5-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/deep_sleep/test.esp32-c61-idf.yaml b/tests/components/deep_sleep/test.esp32-c61-idf.yaml new file mode 100644 index 00000000000..11abe707116 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c61-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml From a0742a953558a25e86597e747b44787c74a2a7b3 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:22:34 +0100 Subject: [PATCH 188/343] [api] Add nRF52 support (#17226) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/api/__init__.py | 2 ++ esphome/components/api/api_pb2_includes.h | 7 +++++++ esphome/components/network/__init__.py | 6 ++++++ tests/components/api/test.nrf52-adafruit.yaml | 4 ++++ 4 files changed, 19 insertions(+) create mode 100644 tests/components/api/test.nrf52-adafruit.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 932702d47ab..0f5cd936f54 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -305,6 +305,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources ln882x=4, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets ): cv.int_range(min=1, max=10), cv.SplitDefault( CONF_MAX_CONNECTIONS, @@ -315,6 +316,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=5, # Moderate RAM host=8, # Abundant resources ln882x=5, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index f45e091c6f4..70ba579fcc3 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -31,6 +31,13 @@ #include #include +#if defined(LOG_LEVEL_NONE) +// Zephyr defines LOG_LEVEL_NONE as a logging macro that collides with the LogLevel enum value of +// the same name in the generated api_pb2.h. Undefine it for the rest of this translation unit so +// the enum parses; nothing below needs Zephyr's logging macro. +#undef LOG_LEVEL_NONE +#endif + namespace esphome::api { // This file only provides includes, no actual code diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b662293ab5c..846c3afc599 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -221,6 +221,12 @@ async def to_code(config): zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) zephyr_add_prj_conf("NET_UDP", True) + # The nRF Connect SDK replaces mbedTLS with PSA/Oberon crypto and does not provide the + # legacy mbedtls_md5() symbol that Zephyr's RFC 6528 TCP ISN generator links against + # (selecting MBEDTLS_MAC_MD5_ENABLED does not bring in the legacy C API here). Disable it so + # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the + # RFC 6528 keyed hash). + zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..9229d68aa3a --- /dev/null +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +network: + enable_ipv6: true + +api: From 690e8c3fb964d82b2b3a42f5a1007f825d0a4d3c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 27 Jun 2026 21:50:28 +0200 Subject: [PATCH 189/343] [nrf52] add upload for native build (#17100) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 173 ++++++++++++- esphome/components/nrf52/framework.py | 5 +- esphome/components/nrf52/requirements.txt | 1 + esphome/storage_json.py | 16 ++ tests/unit_tests/test_nrf52_upload.py | 292 ++++++++++++++++++++++ tests/unit_tests/test_storage_json.py | 96 +++++++ 6 files changed, 571 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_upload.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index d87318b03db..00271c97c70 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging from pathlib import Path import re +import shutil import subprocess from esphome import pins @@ -486,6 +487,16 @@ def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type + if KEY_ZEPHYR not in CORE.data: + platform_config = config.get(CORE.target_platform) + if not platform_config: + raise EsphomeError( + "nRF52 platform configuration is missing; " + "please re-validate and recompile." + ) + set_core_data(platform_config) + set_framework(platform_config) + mcumgr_device: str | None = None if get_port_type(host) == PortType.SERIAL: @@ -494,17 +505,122 @@ def upload_program(config: ConfigType, args, host: str) -> bool: mcumgr_device = host else: if not CORE.using_toolchain_platformio: - raise EsphomeError("Not implemented yet") - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio serial upload + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader not in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + raise EsphomeError("Not implemented yet") + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; " + "it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + # Wait for udev to finish setting up device permissions + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + break + except EsphomeError: + _time.sleep(0.05) + else: + check_permissions(host) # raises with helpful message + + python = str(paths["python_executable"]) + if not run_command_ok( + [ + python, + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ], + env=env, + stream_output=True, + ): + raise EsphomeError("nRF52 serial DFU upload failed") + else: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: serial upload if host == "PYOCD": - result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio PYOCD upload + if not CORE.using_toolchain_platformio: + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "flash", + "--runner", + "pyocd", + "-d", + str(build_dir), + ] + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 pyocd flash failed") + else: + result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths from .ble_logger import is_mac_address @@ -662,4 +778,43 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") + # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and + # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match + # get_download_types (which mirrors the platformio build output layout). + zephyr_dir = build_dir / "zephyr" + west_out = zephyr_dir / "zephyr" + for filename in ["zephyr.uf2"]: + src = west_out / filename + if src.is_file(): + shutil.copy2(src, zephyr_dir / filename) + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + hex_file = west_out / "zephyr.hex" + dfu_package = build_dir / "firmware.zip" + genpkg_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "genpkg", + ] + if bootloader in _GENPKG_PARAMS: + dev_type, sd_req = _GENPKG_PARAMS[bootloader] + genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] + genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] + if not run_command_ok(genpkg_cmd, env=env, stream_output=True): + raise EsphomeError("Failed to create adafruit DFU package") + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index a35ba3ef85d..05feadb0013 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -111,10 +111,9 @@ def _get_version_str() -> str: def get_build_paths() -> dict: version = _get_version_str() + env_path = _get_python_env_path(version) return { - "python_executable": get_python_env_executable_path( - _get_python_env_path(version), "python" - ), + "python_executable": get_python_env_executable_path(env_path, "python"), "framework_path": _get_framework_path(version), } diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt index 250d3a29cfe..c55d35b2b19 100644 --- a/esphome/components/nrf52/requirements.txt +++ b/esphome/components/nrf52/requirements.txt @@ -1,3 +1,4 @@ west==1.5.0 ninja==1.13.0 cmake==4.3.2 +adafruit-nrfutil @ git+https://github.com/adafruit/Adafruit_nRF52_nrfutil.git@7fdfe15feee5f304fb7d9b031721dcefa1f72b58 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index f754673b792..9d662df8f89 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, Toolchain, @@ -179,6 +180,8 @@ class StorageJSON: hardware = esp32.get_esp32_variant(esph) framework_version = str(esp32.idf_version()) + elif esph.is_nrf52: + framework_version = str(esph.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) return StorageJSON( storage_version=1, name=esph.name, @@ -334,6 +337,19 @@ class StorageJSON: f"Please clean the build files and recompile." ) from err CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + import esphome.config_validation as cv + + try: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py new file mode 100644 index 00000000000..a60e23a3376 --- /dev/null +++ b/tests/unit_tests/test_nrf52_upload.py @@ -0,0 +1,292 @@ +"""Tests for esphome.components.nrf52 upload_program and run_compile.""" + +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr.const import ( + KEY_BOARD, + KEY_BOOTLOADER, + KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, + KEY_OVERLAY, + KEY_PM_STATIC, + KEY_PRJ_CONF, + KEY_USER, + KEY_ZEPHYR, +) +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, +) +from esphome.core import CORE, EsphomeError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_nrf52_core( + bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + toolchain: Toolchain = Toolchain.SDK_NRF, + build_path: Path | None = None, +) -> None: + CORE.name = "test_device" + if build_path is not None: + CORE.build_path = build_path + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2), + } + CORE.toolchain = toolchain + CORE.data[KEY_ZEPHYR] = { + KEY_BOARD: "adafruit_feather_nrf52840", + KEY_BOOTLOADER: bootloader, + KEY_PRJ_CONF: {}, + KEY_OVERLAY: {"": ""}, + KEY_EXTRA_BUILD_FILES: {}, + KEY_PM_STATIC: [], + KEY_USER: {}, + KEY_KCONFIG: "", + } + + +def _make_paths(tmp_path: Path) -> dict: + return { + "python_executable": tmp_path / "penv" / "python", + "framework_path": tmp_path / "framework", + } + + +# --------------------------------------------------------------------------- +# Config-reconstruction guard +# --------------------------------------------------------------------------- + + +class TestUploadProgramConfigGuard: + def test_missing_platform_config_raises(self, setup_core: Path) -> None: + """upload_program raises EsphomeError when the platform config section is absent.""" + from esphome.components.nrf52 import upload_program + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + } + # KEY_ZEPHYR absent → reconstruction branch is entered + assert KEY_ZEPHYR not in CORE.data + + with pytest.raises(EsphomeError, match="platform configuration"): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# PYOCD upload path +# --------------------------------------------------------------------------- + + +class TestUploadProgramPyocd: + def test_pyocd_assembles_west_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """West flash command must include --runner pyocd and the build dir.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch("esphome.components.nrf52.get_build_paths", return_value=paths), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch( + "esphome.components.nrf52.run_command_ok", return_value=True + ) as mock_run, + ): + result = upload_program(config={}, args=None, host="PYOCD") + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert str(paths["python_executable"]) == cmd[0] + assert "west" in cmd + assert "flash" in cmd + assert "--runner" in cmd + assert "pyocd" in cmd + assert "-d" in cmd + assert str(build_dir) in cmd + + def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed west flash must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch("esphome.components.nrf52.run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="pyocd"), + ): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# Serial DFU upload path +# --------------------------------------------------------------------------- + + +def _enter_serial_dfu_patches( + stack: ExitStack, host: str, tmp_path: Path, paths: dict +) -> MagicMock: + """Enter all context managers needed for the serial DFU happy path. + + Returns the mock for ``run_command_ok`` so callers can inspect calls. + comports() returns [] on the first call (port disappeared) and a list + containing the host on every subsequent call (port reappeared). Patches + are applied directly on the real pyserial module attributes so they are + visible to the deferred ``import serial[.tools.list_ports] as _x`` + statements inside upload_program. + """ + import serial + import serial.tools.list_ports + + from esphome.upload_targets import PortType + + _comports_calls = [0] + + def _comports(): + _comports_calls[0] += 1 + if _comports_calls[0] == 1: + return [] # port disappeared → disappear loop breaks + return [MagicMock(device=host)] # port back → reappear loop breaks + + stack.enter_context( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) + ) + stack.enter_context(patch("esphome.__main__.check_permissions")) + stack.enter_context(patch("esphome.components.nrf52.check_and_install")) + stack.enter_context( + patch("esphome.components.nrf52.get_build_paths", return_value=paths) + ) + stack.enter_context( + patch("esphome.components.nrf52.get_build_env", return_value={}) + ) + stack.enter_context(patch("time.sleep")) + # Patch directly on the real pyserial module so the deferred imports inside + # upload_program see our mocks regardless of how sys.modules is cached. + stack.enter_context(patch.object(serial, "Serial")) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + return stack.enter_context( + patch("esphome.components.nrf52.run_command_ok", return_value=True) + ) + + +class TestUploadProgramSerialDfu: + def test_unsupported_bootloader_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An unknown bootloader must raise EsphomeError before touching the port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core( + bootloader="unknown_bootloader", build_path=tmp_path / "build" + ) + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + pytest.raises(EsphomeError, match="Not implemented"), + ): + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: + """Missing firmware.zip must raise EsphomeError before opening the serial port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + pytest.raises(EsphomeError, match="Firmware not found"), + ): + # firmware.zip does not exist on disk → is_file() returns False + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_serial_dfu_assembles_nordicsemi_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + result = upload_program(config={}, args=None, host=host) + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "nordicsemi.__main__" in cmd + assert "dfu" in cmd + assert "serial" in cmd + assert "-pkg" in cmd + assert str(dfu_package) in cmd + assert "-p" in cmd + assert host in cmd + assert "--singlebank" in cmd + + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed nordicsemi DFU must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + mock_run.return_value = False + with pytest.raises(EsphomeError, match="serial DFU upload failed"): + upload_program(config={}, args=None, host=host) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 7ba56b05f42..01683507c1f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.web_port = None mock_core.target_platform = "esp8266" mock_core.is_esp32 = False + mock_core.is_nrf52 = False mock_core.build_path = "/build" mock_core.firmware_bin = "/build/firmware.bin" mock_core.loaded_integrations = set() @@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: assert result.toolchain is None +def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None: + """Test from_esphome_core captures the framework version on nRF52.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + mock_core = MagicMock() + mock_core.name = "nrf_device" + mock_core.friendly_name = "nRF Device" + mock_core.comment = None + mock_core.address = "nrf.local" + mock_core.web_port = None + mock_core.target_platform = "nrf52" + mock_core.is_esp32 = False + mock_core.is_nrf52 = True + mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}} + mock_core.build_path = "/build/nrf_device" + mock_core.firmware_bin = "/build/nrf_device/firmware.bin" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "zephyr" + mock_core.toolchain = None + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.target_platform == "NRF52" + assert result.framework_version == "2.9.2" + + def test_storage_json_load_valid_file(tmp_path: Path) -> None: """Test StorageJSON.load with valid JSON file.""" storage_data = { @@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result.esphome_version == "1.14.0" # Should map to esphome_version +def _make_nrf52_storage( + framework_version: str | None = None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="NRF52", + build_path=Path("/build"), + firmware_bin_path=Path("/build/zephyr/zephyr.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="zephyr", + core_platform="nrf52", + framework_version=framework_version, + ) + + +def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION].""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + storage = _make_nrf52_storage("2.9.2") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "2.9.2" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "2.9.2" + + loaded.apply_to_core() + assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2) + + +def test_storage_json_nrf52_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + loaded = _make_nrf52_storage(framework_version=None) + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE] + + +def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_nrf52_storage(framework_version="not-a-version") + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_storage_json_load_area(tmp_path: Path) -> None: """``area`` round-trips through load; absence loads as None.""" file_path = tmp_path / "with_area.json" From fd7fc6b8e8f398c96b230db8f01b4d03135d37d2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:00:23 -0700 Subject: [PATCH 190/343] Bump bundled esphome-device-builder to 1.0.20 (#17244) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 66dad179bbd..5626d18fcc1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 RUN \ platformio settings set enable_telemetry No \ From 0fb100f2d12a5453abbaade229bd9fca419ef163 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:00 -0400 Subject: [PATCH 191/343] [core] Suppress unactionable legacy-redaction warning for substitutions (#17242) --- esphome/__main__.py | 27 ++++++++++++++++++++++----- tests/unit_tests/test_main.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 48fee1e97e9..1062df7167b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1488,12 +1488,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0" def _redact_with_legacy_fallback(output: str) -> str: unmarked: set[str] = set() + # Track the top-level ``substitutions:`` block. Its keys are arbitrary + # user-chosen names with no schema validator, so the ``cv.sensitive(...)`` + # migration named in the warning can't be applied to them. Their values are + # still redacted, but emitting the (unactionable) deprecation warning would + # only confuse users. + in_substitutions = False - def _replace(m: re.Match[str]) -> str: - unmarked.add(m.group("key")) - return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m" - - output = _LEGACY_REDACTION_RE.sub(_replace, output) + lines = output.split("\n") + for i, line in enumerate(lines): + # A non-indented, non-blank line is a top-level key that opens or + # closes the substitutions block. + if line and not line[0].isspace(): + in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:") + m = _LEGACY_REDACTION_RE.search(line) + if m is None: + continue + if not in_substitutions: + unmarked.add(m.group("key")) + lines[i] = ( + f"{line[: m.start()]}{m.group('key')}: " + f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" + ) + output = "\n".join(lines) for key in sorted(unmarked): _LOGGER.warning( "Field '%s' is being redacted by a legacy substring heuristic. " diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b2011259c19..65bf4a583e0 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -442,6 +442,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix( assert not any("legacy substring" in rec.message for rec in caplog.records) +def test_redact_with_legacy_fallback__substitutions_redacted_without_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Substitution keys have no schema validator, so their values are still + redacted but the unactionable cv.sensitive migration warning is suppressed + (see issue #17225).""" + text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n" + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert not any("legacy substring" in rec.message for rec in caplog.records) + + +def test_redact_with_legacy_fallback__warns_after_substitutions_block( + caplog: pytest.LogCaptureFixture, +) -> None: + """The suppression ends at the next top-level key; a sensitive-shaped field + in a later block (a real schema field) still warns, while the substitution + above it does not.""" + text = ( + "substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n" + ) + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + out = _redact_with_legacy_fallback(text) + assert "ota_password: \\033[8mapolloautomation\\033[28m" in out + assert "password: \\033[8mhunter2\\033[28m" in out + assert any("'password'" in rec.message for rec in caplog.records) + assert not any("ota_password" in rec.message for rec in caplog.records) + + def test_command_config__invokes_legacy_fallback_when_redacting( tmp_path: Path, capfd: CaptureFixture[str] ) -> None: From bda789052d67332ea89c811a0b64a77b475e9b3f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:17:09 -0400 Subject: [PATCH 192/343] [espnow] Don't throttle ESP-NOW RX when deep_sleep is present (#17240) --- esphome/components/espnow/espnow_component.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 403e6f4944a..f89b4a2ff1b 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -28,9 +28,6 @@ namespace esphome::espnow { static constexpr const char *TAG = "espnow"; -static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; -static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; - ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const LogString *espnow_error_to_str(esp_err_t error) { @@ -204,11 +201,6 @@ void ESPNowComponent::enable_() { esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); -#ifdef USE_DEEP_SLEEP - esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); - esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); -#endif - this->state_ = ESPNOW_STATE_ENABLED; for (auto peer : this->peers_) { From d3892b8399c7f015bbbced0b50e943dcbacd2b17 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:03:09 -0400 Subject: [PATCH 193/343] [platformio] Extract toolchain-agnostic PlatformIO library converter (#17243) --- esphome/espidf/component.py | 724 ++------------------ esphome/platformio/library.py | 717 +++++++++++++++++++ tests/unit_tests/test_espidf_component.py | 83 +-- tests/unit_tests/test_platformio_library.py | 229 +++++++ 4 files changed, 1023 insertions(+), 730 deletions(-) create mode 100644 esphome/platformio/library.py create mode 100644 tests/unit_tests/test_platformio_library.py diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index cfd42916b2b..5029e014a45 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,163 +1,42 @@ -from collections import deque -from collections.abc import Callable -from dataclasses import dataclass, field -import glob -import hashlib -import itertools -import json +"""ESP-IDF backend for the shared PlatformIO library converter. + +The toolchain-agnostic resolution/download/caching pipeline lives in +``esphome.platformio.library``; this module only adds the ESP-IDF specifics: +emitting an ``idf_component_register`` ``CMakeLists.txt`` + ``idf_component.yml`` +for each resolved library, running any PlatformIO ``extraScript``, and the +ESP-IDF platform/framework compatibility defaults. +""" + import logging import os from pathlib import Path -import re -import tempfile -from typing import Any, TypeVar -from urllib.parse import urlparse, urlsplit, urlunsplit -from esphome import git, yaml_util from esphome.core import CORE, Library -from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + ESPHOME_DATA_EXTRA_CMAKE_KEY, + ESPHOME_DATA_KEY, + SRC_FILE_EXTENSIONS, + ConvertedLibrary as IDFComponent, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) _LOGGER = logging.getLogger(__name__) -PathType = str | os.PathLike - -# -# Constants from platformio -# - -FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") -DEFAULT_BUILD_SRC_FILTER = ( - "+<*> -<.git/> -<.svn/> - - - -" -) -DEFAULT_BUILD_SRC_DIRS = "src" -DEFAULT_BUILD_INCLUDE_DIR = "include" -DEFAULT_BUILD_FLAGS = [] -SRC_FILE_EXTENSIONS = [ - ".c", - ".cpp", - ".cc", - ".cxx", - ".c++", - ".S", - ".spp", - ".SPP", - ".sx", - ".s", - ".asm", - ".ASM", -] - ESP32_PLATFORM = "espressif32" -DOMAIN = "pio_components" - -ESPHOME_DATA_KEY = "ESPHOME" -ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" -class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - raise NotImplementedError - - -class URLSource(Source): - def __init__(self, url: str): - self.url = url - - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - base_dir = Path(CORE.data_dir) / DOMAIN - h = hashlib.new("sha256") - h.update(self.url.encode()) - if salt: - h.update(salt.encode()) - path = base_dir / h.hexdigest()[:8] / dir_suffix - # Marker file written last to signal a complete extraction. Using a - # marker (instead of just `path.is_dir()`) means an interrupted - # extraction is correctly detected and re-run on the next invocation, - # and lets us extract directly into ``path`` — avoiding a - # post-extraction rename that races with antivirus on Windows. - extracted_marker = path / ".esphome_extracted" - if not extracted_marker.is_file() or force: - rmdir(path, msg=f"Clean up library directory {path}") - - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s ...", self.url) - _LOGGER.debug("Location: %s", path) - - download_from_mirrors([self.url], {}, tmp.file) - - _LOGGER.debug("Extracting archive to %s ...", path) - archive_extract_all(tmp.file, path) - extracted_marker.touch() - return path - - def __str__(self): - return self.url - - -class GitSource(Source): - def __init__(self, url: str, ref: str | None): - self.url = url - self.ref = ref - - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - path, _ = git.clone_or_update( - url=self.url, - ref=self.ref, - refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, - submodules=[], - subpath=Path(dir_suffix), - ) - return path - - def __str__(self): - return f"{self.url}#{self.ref}" if self.ref else self.url - - -class InvalidIDFComponent(Exception): - pass - - -class IDFComponent: - def __init__(self, name: str, version: str, source: Source | None): - self.name = name - self.version = version - self.source = source - self.data = {} - self.dependencies: list[IDFComponent] = [] - self._path: Path | None = None - - def __str__(self): - return f"{self.name}@{self.version}={self.source}" - - @property - def path(self) -> Path: - if self._path is None: - raise RuntimeError(f"path not set for component {self}") - return self._path - - @path.setter - def path(self, value: Path) -> None: - self._path = value - - def get_sanitized_name(self): - return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) - - def get_require_name(self): - return self.get_sanitized_name().replace("/", "__") - - def download(self, force: bool = False, salt: str = ""): - """ - The dependency name should match the directory name at the end of the override path. - The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. - If you want to specify the full name of the component with the namespace, replace / in the component name with __. - @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html - """ - self.path = self.source.download( - self.get_sanitized_name(), force=force, salt=salt - ) +def _idf_framework() -> str: + """The framework token an ESP-IDF library manifest is expected to declare.""" + return "arduino" if CORE.using_arduino else "espidf" def _apply_extra_script(component: IDFComponent) -> None: @@ -190,119 +69,6 @@ def _apply_extra_script(component: IDFComponent) -> None: component.data["build"]["flags"] = flags -T = TypeVar("T") - - -def _ensure_list(obj: T | list[T]) -> list[T]: - """ - Convert an object to a list if it isn't already a list. - - Args: - obj: Object that may or may not already be a list. - - Returns: - list[T]: The original list if ``obj`` is a list, otherwise a single-item - list containing ``obj``. - """ - return [obj] if not isinstance(obj, list) else obj - - -def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: - """ - Convert owner and package name to a standardized component name. - - This function combines owner and package name with a forward slash when - both are provided, otherwise returns just the package name. - - Args: - owner: The owner/username of the package (can be None) - pkgname: The name of the package - - Returns: - str: The standardized component name in "owner/pkgname" format or just "pkgname" - """ - return f"{owner}/{pkgname}" if owner else pkgname - - -def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: - """ - Recursively match files in a directory according to include/exclude patterns. - - This function processes a list of filter strings that indicate which files - to include or exclude. Each filter is parsed into patterns with a sign: - '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' - are normalized to include all their contents recursively. - - Args: - src_dir (PathType): Root directory to search within. - src_filters (list[str]): List of filter strings, which may contain multiple - patterns. Each pattern can start with '+' or '-' to indicate inclusion - or exclusion. - - Returns: - list[str]: List of matched file paths as strings. Only files (not directories) - are returned, even if a directory matches a pattern. - """ - matches = list( - itertools.chain.from_iterable( - FILTER_REGEX.findall(src_filter) for src_filter in src_filters - ) - ) - - selected = set() - - for sign, pattern in matches: - pattern = pattern.strip() - - if pattern.endswith("/"): - pattern = pattern.rstrip("/") + "/**" - - # glob.escape has no pathlib equivalent and the matcher works on raw - # path strings, so PTH118/PTH207 don't apply here. - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 - - matched = [] - for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 - if not Path(item).is_dir(): - matched.append(item) - else: - # PlatformIO quirk: a directory matched with "*" should include all its - # nested files and subdirectories, not just the directory itself. - for root, _, files in os.walk(item): - matched.extend([str(Path(root) / f) for f in files]) - - if sign == "+": - selected.update(matched) - elif sign == "-": - selected.difference_update(matched) - - return [r for r in selected if Path(r).is_file()] - - -def _split_list_by_condition( - items: list[str], match_fn: Callable[[str], str | None] -) -> tuple[list[str], list[str]]: - """ - Splits a list into two lists based on a matching function. - - Args: - items: List of items to split. - match_fn: Function that returns a value for items that should go into the "matched" list. - - Returns: - A tuple (matched, non_matched) - """ - matched = [] - non_matched = [] - for item in items: - result = match_fn(item) - if result: - matched.append(result) - else: - non_matched.append(item) - return matched, non_matched - - def generate_cmakelists_txt(component: IDFComponent) -> str: """ Generate a CMakeLists.txt file for an ESP-IDF component. @@ -333,15 +99,15 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_include_dir = component.data.get("build", {}).get( "includeDir", DEFAULT_BUILD_INCLUDE_DIR ) - build_src_filter = _ensure_list( + build_src_filter = ensure_list( component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER) ) - build_flags = _ensure_list( + build_flags = ensure_list( component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) # List all sources files - build_src_files = _collect_filtered_files( + build_src_files = collect_filtered_files( component.path / Path(build_src_dir), build_src_filter ) @@ -361,13 +127,13 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Handle build flags - include_dir_flags, build_flags = _split_list_by_condition( + include_dir_flags, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None ) - link_directories, build_flags = _split_list_by_condition( + link_directories, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None ) - link_libraries, build_flags = _split_list_by_condition( + link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) @@ -379,7 +145,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Split build_flags list into private and public lists - private_build_flags, public_build_flags = _split_list_by_condition( + private_build_flags, public_build_flags = split_list_by_condition( build_flags, lambda a: a if a.startswith("-W") else None ) @@ -453,6 +219,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: Returns: YAML string representation of ESP-IDF component configuration """ + from esphome import yaml_util + data = {} description = component.data.get("description") @@ -477,410 +245,24 @@ def generate_idf_component_yml(component: IDFComponent) -> str: return yaml_util.dump(data) -def _check_library_data(data: dict): - """ - Check if a library data is compatible with the ESP-IDF framework. - - A platform mismatch (e.g. an AVR-only library on ESP32) raises - ``InvalidIDFComponent`` so the caller skips the library. A framework - mismatch only logs a warning — PIO manifests often understate the - frameworks they actually compile under, and IDF (unlike PIO's - ``lib_compat_mode``) has no opt-out, so we include the library anyway. - - Args: - data: PIO library manifest dict being processed. - - Raises: - InvalidIDFComponent: If the library does not support the ESP32 platform. - """ - platforms = data.get("platforms", "*") - if isinstance(platforms, str): - platforms = [a.strip() for a in platforms.split(",")] - platforms = _ensure_list(platforms) - - # Check if library supports ESP-IDF platform - valid_platforms = "*" in platforms or ESP32_PLATFORM in platforms - - if not valid_platforms: - raise InvalidIDFComponent(f"Unsupported library platforms: {platforms}") - - frameworks = data.get("frameworks", "*") - if isinstance(frameworks, str): - frameworks = [a.strip() for a in frameworks.split(",")] - frameworks = _ensure_list(frameworks) - - # Check if library declares the active framework. PIO library manifests - # often list only "arduino" even when the library actually compiles fine - # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to - # opt out of the check. Warn instead of failing so the user isn't forced to - # fork the library to fix the manifest. - framework = "arduino" if CORE.using_arduino else "espidf" - valid_framework = "*" in frameworks or framework in frameworks - - if not valid_framework: - _LOGGER.warning( - "Library %s declares frameworks %s that do not include '%s'; including anyway", - data.get("name", ""), - frameworks, - framework, - ) - - -def _parse_library_json(library_json_path: PathType): - """ - Load and parse a JSON file describing a library. - - Args: - library_json_path (PathType): Path to the JSON file. - - Returns: - dict: Parsed JSON content as a Python dictionary. - """ - with Path(library_json_path).open(encoding="utf8") as fp: - return json.load(fp) - - -def _parse_library_properties(library_properties_path: PathType): - """ - Parse a key-value platformio .properties style file into a dictionary. - - Args: - library_properties_path (PathType): Path to the properties file. - - Returns: - dict[str, str]: Mapping of parsed property keys to values. - """ - with Path(library_properties_path).open(encoding="utf8") as fp: - data = {} - for line in fp.read().splitlines(): - line = line.strip() - if not line or "=" not in line: - continue - # skip comments - if line.startswith("#"): - continue - key, value = line.split("=", 1) - if not value.strip(): - continue - data[key.strip()] = value.strip() - return data - - -def _make_registry_client() -> Any: - """Create a minimal PlatformIO registry client with no system filtering. - - ``is_system_compatible`` is forced True so version selection is driven purely - by the requested version requirements -- ESP-IDF/target compatibility is - handled elsewhere, not by the PlatformIO registry. - """ - from platformio.package.manager._registry import PackageManagerRegistryMixin - - class _Registry(PackageManagerRegistryMixin): - def __init__(self) -> None: - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value: Any, custom_system: Any = None) -> bool: - return True - - return _Registry() - - -def _resolve_registry_version( - owner: str | None, pkgname: str, requirements: set[str] -) -> tuple[str, str, str, str]: - """Resolve a registry package to the single highest version satisfying ALL - the given requirements; return ``(owner, name, version, download_url)``. - - Intersecting every requirement (rather than resolving each consumer in - isolation) makes the result independent of processing order and guarantees - no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as - both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. - """ - from platformio.package.meta import PackageSpec - - registry = _make_registry_client() - package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) - owner = package["owner"]["username"] - name = package["name"] - - # Chaining the per-requirement filter intersects all constraints. - versions = package.get("versions") or [] - for requirement in sorted(requirements): - versions = registry.get_compatible_registry_versions( - versions, PackageSpec(owner=owner, name=name, requirements=requirement) - ) - if not versions: - raise RuntimeError( - f"No version of {owner}/{name} satisfies all requirements " - f"{sorted(requirements)} requested across the library tree" - ) - - best = registry.pick_best_registry_version(versions) - pkgfile = registry.pick_compatible_pkg_file(best["files"]) - if not pkgfile: - raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") - return owner, name, best["name"], pkgfile["download_url"] - - -def _normalize_dependencies(dependencies: Any) -> list[dict]: - """Normalize a library manifest's ``dependencies`` to a list of dicts. - - PIO's library.json accepts both the list-of-dicts form and the shorthand - dict form (``{"owner/Name": "version_spec"}``); normalize the latter so - callers see a uniform list. - """ - if not dependencies: - return [] - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - return normalized - return [d for d in dependencies if isinstance(d, dict)] - - -@dataclass -class _LibNode: - """A node in the library dependency graph being resolved as a batch.""" - - key: str - is_git: bool - owner: str | None = None - pkgname: str | None = None - requirements: set[str] = field(default_factory=set) - url: str | None = None - ref: str | None = None - edges: set[str] = field(default_factory=set) - - -def _node_key( - name: str | None, version: str | None, repository: str | None -) -> tuple[str, bool, tuple[str | None, str | None]]: - """Return ``(key, is_git, locator)`` for a library or dependency spec. - - The key is derived from the *input* spec (the registry name as written, or - the git URL path), not the resolved canonical name. So a package referenced - inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps - to distinct keys and isn't deduplicated; ``generate_idf_components`` warns - about that after resolution rather than merging the nodes. - """ - if repository: - split_result = urlsplit(repository) - key = str(split_result.path).strip("/").removesuffix(".git") - ref = split_result.fragment.strip() or None - url = urlunsplit(split_result._replace(fragment="")) - return key, True, (url, ref) - if name and "/" in name: - owner, pkgname = name.split("/", 1) - else: - owner, pkgname = None, name - return name, False, (owner, pkgname) +def _emit_idf_component(component: IDFComponent) -> None: + """Write the ESP-IDF build files for a resolved library into its cache dir.""" + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: - """Resolve and convert a batch of PlatformIO libraries to IDF components. - - Resolves the whole set together rather than each library independently: it - walks the dependency graph collecting every version *requirement* per - component name, then resolves each name once to a single version satisfying - all of them. So a transitive dependency shared under - different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and - ``esp_wireguard``) becomes one component instead of two clashing - ``override_path`` entries -- order-independently, and without ever violating - a stated constraint. - - The returned list holds the top-level components (those directly requested); - transitive dependencies are converted too and wired into each component's - generated manifest. - - ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by - short name (part after the ``/``), matched against both the top-level - libraries and every dependency discovered during the graph walk. - """ - nodes: dict[str, _LibNode] = {} - - lib_ignore = { - name.split("/")[-1].lower() - for name in CORE.platformio_options.get("lib_ignore", []) - } - - # The generated CMakeLists.txt/idf_component.yml inside the shared cache - # bake in the dependency wiring, which lib_ignore changes; salt the cache - # path so configs with different lib_ignore values don't fight over (and - # constantly rewrite) the same converted component files. - salt = ( - hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] - if lib_ignore - else "" + """Resolve and convert a batch of PlatformIO libraries to IDF components.""" + backend = LibraryBackend( + platform=ESP32_PLATFORM, + framework=_idf_framework(), + emit=_emit_idf_component, ) - - def is_ignored(name: str | None) -> bool: - if not lib_ignore or name is None: - return False - return name.split("/")[-1].lower() in lib_ignore - - def add_spec(name: str | None, version: str | None, repository: str | None) -> str: - key, is_git, locator = _node_key(name, version, repository) - node = nodes.get(key) or _LibNode(key=key, is_git=is_git) - nodes[key] = node - if is_git: - node.is_git = True - node.url, node.ref = locator - else: - node.owner, node.pkgname = locator - if version: - node.requirements.add(version) - return key - - top_level = [ - add_spec(library.name, library.version, library.repository) - for library in libraries - if not is_ignored(library.name) - ] - - # Collect + resolve to a fixpoint: a node is (re)resolved whenever its - # requirement set has grown since the last time, so every requirement in the - # graph is accounted for before conversion. - components: dict[str, IDFComponent] = {} - resolved_requirements: dict[str, frozenset[str]] = {} - top_level_keys = set(top_level) - worklist = deque(dict.fromkeys(top_level)) - while worklist: - key = worklist.popleft() - node = nodes[key] - - # A node is queued once per referring edge; skip the (uncached) registry - # lookup + download + dependency walk unless its requirement set grew - # since the last resolve. Requirements only ever grow, so this still - # converges the fixpoint and terminates dependency cycles. - requirements = frozenset(node.requirements) - if resolved_requirements.get(key) == requirements: - continue - resolved_requirements[key] = requirements - - if node.is_git: - component = IDFComponent(key, "*", GitSource(node.url, node.ref)) - else: - owner, name, version, url = _resolve_registry_version( - node.owner, node.pkgname, node.requirements - ) - component = IDFComponent( - _owner_pkgname_to_name(owner, name), version, URLSource(url) - ) - component.download(salt=salt) - - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: - raise RuntimeError( - f"Invalid PIO library {key}: missing library.json and " - "library.properties" - ) - - try: - _check_library_data(component.data) - except InvalidIDFComponent as e: - # Skip an incompatible transitive dependency, but fail fast if a - # top-level library the build explicitly requested is incompatible. - if key in top_level_keys: - raise RuntimeError( - f"Requested library {key} is not compatible with ESP-IDF: {e}" - ) from e - _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) - continue - components[key] = component - - # Requirements changed (we got past the short-circuit above), so - # (re)walk this component's dependencies. - node.edges = set() - for dependency in _normalize_dependencies(component.data.get("dependencies")): - if "name" not in dependency or "version" not in dependency: - continue - try: - _check_library_data(dependency) - except InvalidIDFComponent as e: - _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) - continue - dep_name = _owner_pkgname_to_name( - dependency.get("owner"), dependency.get("name") - ) - if is_ignored(dep_name): - _LOGGER.debug("Skip ignored dependency %s", dep_name) - continue - # The version field may actually be a URL (git/archive dependency). - dep_version = dependency["version"] - dep_url = None - try: - parsed = urlparse(dep_version) - if all([parsed.scheme, parsed.netloc]): - dep_url, dep_version = dep_version, None - except (TypeError, ValueError): - pass - dep_key = add_spec(dep_name, dep_version, dep_url) - node.edges.add(dep_key) - worklist.append(dep_key) - - # A git source wins over any registry version requested for the same - # component. That's intentional, but warn so a dropped registry pin isn't a - # silent surprise. - for node in nodes.values(): - if node.is_git and node.requirements: - _LOGGER.warning( - "Library %s is requested both from a git source (%s) and as " - "registry version(s) %s; using the git source.", - node.key, - node.url, - sorted(node.requirements), - ) - - # Two graph nodes that resolve to the same component name (e.g. a package - # referenced both bare and as ``owner/name``) are not deduplicated and can - # produce conflicting component definitions. Warn so it's not silent. - canonical_keys: dict[str, str] = {} - for node_key, component in components.items(): - canonical = component.get_sanitized_name() - if canonical_keys.setdefault(canonical, node_key) != node_key: - _LOGGER.warning( - "Library %s is referenced under multiple names (%s and %s); these " - "are not deduplicated. Reference it consistently as %s.", - canonical, - canonical_keys[canonical], - node_key, - canonical, - ) - - # Wire each component's dependencies to the single resolved instances, then - # regenerate build files. - for key, component in components.items(): - component.dependencies = [ - components[dep_key] - for dep_key in sorted(nodes[key].edges) - if dep_key in components - ] - for component in components.values(): - _apply_extra_script(component) - write_file_if_changed( - component.path / "CMakeLists.txt", - generate_cmakelists_txt(component), - ) - write_file_if_changed( - component.path / "idf_component.yml", - generate_idf_component_yml(component), - ) - - return [components[key] for key in top_level if key in components] + return convert_libraries(libraries, backend) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py new file mode 100644 index 00000000000..43282c7aa02 --- /dev/null +++ b/esphome/platformio/library.py @@ -0,0 +1,717 @@ +"""Toolchain-agnostic PlatformIO library converter. + +Resolves a batch of PlatformIO/Arduino library specs (added via +``cg.add_library(...)``) into local, build-ready directories: it fetches each +library (registry/git/url), parses its ``library.json`` / ``library.properties`` +manifest, resolves the whole dependency graph to a single version per name, and +caches the result under ``/pio_components``. + +The toolchain-specific part — turning a resolved library into build files +(ESP-IDF ``idf_component_register`` CMakeLists, or a Zephyr module) — is supplied +by a :class:`LibraryBackend`. This module owns everything that is the same +regardless of which toolchain consumes the result. +""" + +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +import glob +import hashlib +import itertools +import json +import logging +import os +from pathlib import Path +import re +import tempfile +from typing import Any, TypeVar +from urllib.parse import urlparse, urlsplit, urlunsplit + +from esphome import git +from esphome.core import CORE, Library +from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir + +_LOGGER = logging.getLogger(__name__) + +PathType = str | os.PathLike + +# +# Constants from platformio +# + +FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") +DEFAULT_BUILD_SRC_FILTER = ( + "+<*> -<.git/> -<.svn/> - - - -" +) +DEFAULT_BUILD_SRC_DIRS = "src" +DEFAULT_BUILD_INCLUDE_DIR = "include" +DEFAULT_BUILD_FLAGS = [] +SRC_FILE_EXTENSIONS = [ + ".c", + ".cpp", + ".cc", + ".cxx", + ".c++", + ".S", + ".spp", + ".SPP", + ".sx", + ".s", + ".asm", + ".ASM", +] + +DOMAIN = "pio_components" + +ESPHOME_DATA_KEY = "ESPHOME" +ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" + + +class Source: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + raise NotImplementedError + + +class URLSource(Source): + def __init__(self, url: str): + self.url = url + + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + base_dir = Path(CORE.data_dir) / DOMAIN + h = hashlib.new("sha256") + h.update(self.url.encode()) + if salt: + h.update(salt.encode()) + path = base_dir / h.hexdigest()[:8] / dir_suffix + # Marker file written last to signal a complete extraction. Using a + # marker (instead of just `path.is_dir()`) means an interrupted + # extraction is correctly detected and re-run on the next invocation, + # and lets us extract directly into ``path`` — avoiding a + # post-extraction rename that races with antivirus on Windows. + extracted_marker = path / ".esphome_extracted" + if not extracted_marker.is_file() or force: + rmdir(path, msg=f"Clean up library directory {path}") + + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s ...", self.url) + _LOGGER.debug("Location: %s", path) + + download_from_mirrors([self.url], {}, tmp.file) + + _LOGGER.debug("Extracting archive to %s ...", path) + archive_extract_all(tmp.file, path) + extracted_marker.touch() + return path + + def __str__(self): + return self.url + + +class GitSource(Source): + def __init__(self, url: str, ref: str | None): + self.url = url + self.ref = ref + + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + path, _ = git.clone_or_update( + url=self.url, + ref=self.ref, + refresh=git.NEVER_REFRESH if not force else None, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, + submodules=[], + subpath=Path(dir_suffix), + ) + return path + + def __str__(self): + return f"{self.url}#{self.ref}" if self.ref else self.url + + +class InvalidLibrary(Exception): + pass + + +class ConvertedLibrary: + """A resolved PlatformIO library plus its parsed manifest and on-disk path. + + Toolchain-neutral: ESP-IDF treats it as a component, Zephyr as a module. The + backend reads ``name``/``version``/``data``/``dependencies``/``path`` to emit + its build files. + """ + + def __init__(self, name: str, version: str, source: Source | None): + self.name = name + self.version = version + self.source = source + self.data = {} + self.dependencies: list[ConvertedLibrary] = [] + self._path: Path | None = None + + def __str__(self): + return f"{self.name}@{self.version}={self.source}" + + @property + def path(self) -> Path: + if self._path is None: + raise RuntimeError(f"path not set for library {self}") + return self._path + + @path.setter + def path(self, value: Path) -> None: + self._path = value + + def get_sanitized_name(self): + return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) + + def get_require_name(self): + return self.get_sanitized_name().replace("/", "__") + + def download(self, force: bool = False, salt: str = ""): + """Fetch the library into the shared cache and record its ``path``. + + The cache directory is named after the sanitized library name; backends + rely on that name to identify the unit they build (e.g. ESP-IDF uses the + directory name as the component name, replacing ``/`` with ``__`` via + ``get_require_name``). + """ + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) + + +@dataclass +class LibraryBackend: + """Toolchain hooks for :func:`convert_libraries`. + + ``platform``/``framework`` drive the manifest compatibility check. + ``emit`` writes the toolchain-specific build files into a resolved library's + ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a + Zephyr ``module.yml`` + ``CMakeLists.txt``). + """ + + platform: str + framework: str + emit: Callable[["ConvertedLibrary"], None] + + +T = TypeVar("T") + + +def ensure_list(obj: T | list[T]) -> list[T]: + """ + Convert an object to a list if it isn't already a list. + + Args: + obj: Object that may or may not already be a list. + + Returns: + list[T]: The original list if ``obj`` is a list, otherwise a single-item + list containing ``obj``. + """ + return [obj] if not isinstance(obj, list) else obj + + +def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: + """ + Convert owner and package name to a standardized component name. + + This function combines owner and package name with a forward slash when + both are provided, otherwise returns just the package name. + + Args: + owner: The owner/username of the package (can be None) + pkgname: The name of the package + + Returns: + str: The standardized component name in "owner/pkgname" format or just "pkgname" + """ + return f"{owner}/{pkgname}" if owner else pkgname + + +def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: + """ + Recursively match files in a directory according to include/exclude patterns. + + This function processes a list of filter strings that indicate which files + to include or exclude. Each filter is parsed into patterns with a sign: + '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' + are normalized to include all their contents recursively. + + Args: + src_dir (PathType): Root directory to search within. + src_filters (list[str]): List of filter strings, which may contain multiple + patterns. Each pattern can start with '+' or '-' to indicate inclusion + or exclusion. + + Returns: + list[str]: List of matched file paths as strings. Only files (not directories) + are returned, even if a directory matches a pattern. + """ + matches = list( + itertools.chain.from_iterable( + FILTER_REGEX.findall(src_filter) for src_filter in src_filters + ) + ) + + selected = set() + + for sign, pattern in matches: + pattern = pattern.strip() + + if pattern.endswith("/"): + pattern = pattern.rstrip("/") + "/**" + + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 + + matched = [] + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): + matched.append(item) + else: + # PlatformIO quirk: a directory matched with "*" should include all its + # nested files and subdirectories, not just the directory itself. + for root, _, files in os.walk(item): + matched.extend([str(Path(root) / f) for f in files]) + + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. + if sign == "+": + selected.update(matched) + else: + selected.difference_update(matched) + + return [r for r in selected if Path(r).is_file()] + + +def split_list_by_condition( + items: list[str], match_fn: Callable[[str], str | None] +) -> tuple[list[str], list[str]]: + """ + Splits a list into two lists based on a matching function. + + Args: + items: List of items to split. + match_fn: Function that returns a value for items that should go into the "matched" list. + + Returns: + A tuple (matched, non_matched) + """ + matched = [] + non_matched = [] + for item in items: + result = match_fn(item) + if result: + matched.append(result) + else: + non_matched.append(item) + return matched, non_matched + + +def check_library_data(data: dict, platform: str, framework: str): + """ + Check whether a library manifest is compatible with the target toolchain. + + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidLibrary`` so the caller skips the library. A framework mismatch only + logs a warning — PIO manifests often understate the frameworks they actually + compile under, and there's no opt-out at this layer, so we include the library + anyway. + + Args: + data: PIO library manifest dict being processed. + platform: The PlatformIO platform token the build targets (e.g. + ``espressif32``). + framework: The active framework name (e.g. ``espidf``, ``arduino``, + ``zephyr``) the manifest is expected to declare. + + Raises: + InvalidLibrary: If the library does not support the target platform. + """ + platforms = data.get("platforms", "*") + if isinstance(platforms, str): + platforms = [a.strip() for a in platforms.split(",")] + platforms = ensure_list(platforms) + + # Check if library supports the target platform + valid_platforms = "*" in platforms or platform in platforms + + if not valid_platforms: + raise InvalidLibrary(f"Unsupported library platforms: {platforms}") + + frameworks = data.get("frameworks", "*") + if isinstance(frameworks, str): + frameworks = [a.strip() for a in frameworks.split(",")] + frameworks = ensure_list(frameworks) + + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under the target framework, and there's no way to opt out of the check at + # this layer. Warn instead of failing so the user isn't forced to fork the + # library to fix the manifest. + valid_framework = "*" in frameworks or framework in frameworks + + if not valid_framework: + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) + + +def _parse_library_json(library_json_path: PathType): + """ + Load and parse a JSON file describing a library. + + Args: + library_json_path (PathType): Path to the JSON file. + + Returns: + dict: Parsed JSON content as a Python dictionary. + """ + with Path(library_json_path).open(encoding="utf8") as fp: + return json.load(fp) + + +def _parse_library_properties(library_properties_path: PathType): + """ + Parse a key-value platformio .properties style file into a dictionary. + + Args: + library_properties_path (PathType): Path to the properties file. + + Returns: + dict[str, str]: Mapping of parsed property keys to values. + """ + with Path(library_properties_path).open(encoding="utf8") as fp: + data = {} + for line in fp.read().splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + # skip comments + if line.startswith("#"): + continue + key, value = line.split("=", 1) + if not value.strip(): + continue + data[key.strip()] = value.strip() + return data + + +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- target compatibility is handled + elsewhere, not by the PlatformIO registry. + """ + from platformio.package.manager._registry import PackageManagerRegistryMixin + + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" + + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True + + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. + """ + from platformio.package.meta import PackageSpec + + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] + + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: + raise RuntimeError( + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" + ) + + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] + + +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. + """ + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] + + +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" + + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``convert_libraries`` warns about + that after resolution rather than merging the nodes. + """ + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def convert_libraries( + libraries: list[Library], backend: LibraryBackend +) -> list[ConvertedLibrary]: + """Resolve and convert a batch of PlatformIO libraries for ``backend``. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. ``backend.emit`` is called once per converted library to + write its toolchain-specific build files. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. + """ + nodes: dict[str, _LibNode] = {} + + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated build files inside the shared cache bake in the dependency + # wiring, which lib_ignore changes; salt the cache path so configs with + # different lib_ignore values don't fight over (and constantly rewrite) the + # same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: + key, is_git, locator = _node_key(name, version, repository) + node = nodes.get(key) or _LibNode(key=key, is_git=is_git) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + top_level = [ + add_spec(library.name, library.version, library.repository) + for library in libraries + if not is_ignored(library.name) + ] + + # Collect + resolve to a fixpoint: a node is (re)resolved whenever its + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, ConvertedLibrary] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = ConvertedLibrary( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download(salt=salt) + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + check_library_data(component.data, backend.platform, backend.framework) + except InvalidLibrary as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with " + f"{backend.framework}: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + check_library_data(dependency, backend.platform, backend.framework) + except InvalidLibrary as e: + _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) + continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue + # The version field may actually be a URL (git/archive dependency). + dep_version = dependency["version"] + dep_url = None + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec(dep_name, dep_version, dep_url) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # emit build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + backend.emit(component) + + return [components[key] for key in top_level if key in components] diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 87e168dc94b..d43a1d52769 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -14,23 +14,23 @@ from esphome.const import ( Platform, ) from esphome.core import CORE, Library -import esphome.espidf.component from esphome.espidf.component import ( + generate_cmakelists_txt, + generate_idf_component_yml, + generate_idf_components, +) +import esphome.platformio.library +from esphome.platformio.library import ( + ConvertedLibrary as IDFComponent, GitSource, - IDFComponent, - InvalidIDFComponent, URLSource, - _check_library_data, - _collect_filtered_files, _node_key, _normalize_dependencies, _parse_library_json, _parse_library_properties, _resolve_registry_version, - _split_list_by_condition, - generate_cmakelists_txt, - generate_idf_component_yml, - generate_idf_components, + collect_filtered_files, + split_list_by_condition, ) @@ -70,7 +70,7 @@ def test_collect_filtered_files_basic(tmp_path): f2.parent.mkdir(parents=True) f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*>"]) + result = collect_filtered_files(tmp_path, ["+<*>"]) assert str(f1) in result assert str(f2) in result @@ -81,7 +81,7 @@ def test_collect_filtered_files_exclude(tmp_path): f1.write_text("int a;") f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) + result = collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) assert str(f1) in result assert str(f2) not in result @@ -89,7 +89,7 @@ def test_collect_filtered_files_exclude(tmp_path): def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] - matched, rest = _split_list_by_condition( + matched, rest = split_list_by_condition( items, lambda x: x[2:] if x.startswith("-I") else None ) @@ -202,41 +202,6 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component): generate_idf_component_yml(tmp_component) -def test_check_library_data_valid(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "*"}) - - -def test_check_library_data_valid2(esp32_idf_core): - _check_library_data({"platforms": "*"}) - - -def test_check_library_data_valid3(esp32_idf_core): - _check_library_data({}) - - -def test_check_library_data_valid4(esp32_idf_core): - _check_library_data({"platforms": "espressif32", "frameworks": "*"}) - - -def test_check_library_data_valid5(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "espidf"}) - - -def test_check_library_data_invalid_platform(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": ["other"], "frameworks": "*"}) - - -def test_check_library_data_invalid_framework( - esp32_idf_core: None, caplog: pytest.LogCaptureFixture -) -> None: - # Framework mismatch is a warning, not a hard skip: the library is still - # included so that PIO manifests that only list "arduino" (but actually - # compile under IDF) can be used without forking them. - _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) - assert "do not include 'espidf'" in caplog.text - - def test_extra_script_captures_libpath_libs_and_defines(tmp_path): from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script @@ -453,7 +418,7 @@ def _patch_registry(monkeypatch, versions): ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on the canned data so the intersection logic is exercised for real. """ - registry = esphome.espidf.component._make_registry_client() + registry = esphome.platformio.library._make_registry_client() monkeypatch.setattr( registry, "fetch_registry_package", @@ -467,7 +432,7 @@ def _patch_registry(monkeypatch, versions): }, ) monkeypatch.setattr( - esphome.espidf.component, "_make_registry_client", lambda: registry + esphome.platformio.library, "_make_registry_client", lambda: registry ) @@ -535,7 +500,7 @@ def test_generate_idf_components_dedupes_shared_dependency( return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) top = generate_idf_components( @@ -594,7 +559,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) # lib_ignore is read from CORE.platformio_options (stored there by # _add_platformio_options); matched by lowercase short name. @@ -640,7 +605,7 @@ def test_generate_idf_components_handles_dependency_cycle( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -697,7 +662,7 @@ def test_generate_idf_components_git_overrides_registry_warns( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -733,7 +698,7 @@ def test_generate_idf_components_missing_manifest_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -777,7 +742,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( monkeypatch.setattr(IDFComponent, "download", fake_download) # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner or "owner", @@ -810,7 +775,7 @@ def test_generate_idf_components_incompatible_top_level_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -820,7 +785,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ), ) - with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + with pytest.raises(RuntimeError, match="not compatible with espidf"): generate_idf_components([Library("esphome/A", "1.0.0", None)]) @@ -846,7 +811,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -892,7 +857,7 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: return Path("/cloned"), None monkeypatch.setattr( - esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + esphome.platformio.library.git, "clone_or_update", fake_clone_or_update ) source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py new file mode 100644 index 00000000000..55bc396c25a --- /dev/null +++ b/tests/unit_tests/test_platformio_library.py @@ -0,0 +1,229 @@ +"""Tests for the toolchain-agnostic PlatformIO library converter. + +Covers the shared download/parse/resolve/dependency-walk paths in +``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are +exercised in their own test modules).""" + +import json +import logging +from pathlib import Path + +import pytest + +from esphome.core import Library +import esphome.platformio.library as lib +from esphome.platformio.library import ( + ConvertedLibrary, + GitSource, + InvalidLibrary, + LibraryBackend, + Source, + URLSource, + _resolve_registry_version, + check_library_data, + convert_libraries, +) + + +def _backend(emit=lambda component: None) -> LibraryBackend: + return LibraryBackend(platform="espressif32", framework="espidf", emit=emit) + + +def test_check_library_data_accepts_wildcards(): + check_library_data({"platforms": "*", "frameworks": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_missing_frameworks(): + check_library_data({"platforms": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_empty_manifest(): + check_library_data({}, "espressif32", "espidf") + + +def test_check_library_data_accepts_matching_platform(): + check_library_data( + {"platforms": "espressif32", "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_accepts_matching_framework(): + check_library_data( + {"platforms": "*", "frameworks": "espidf"}, "espressif32", "espidf" + ) + + +def test_check_library_data_rejects_unsupported_platform(): + with pytest.raises(InvalidLibrary): + check_library_data( + {"platforms": ["other"], "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_warns_on_framework_mismatch( + caplog: pytest.LogCaptureFixture, +): + # Framework mismatch is a warning, not a hard skip: the library is still + # included so manifests that only list "arduino" (but compile fine under the + # target framework) can be used without forking them. + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + check_library_data( + {"name": "lib", "platforms": "*", "frameworks": ["other"]}, + "espressif32", + "espidf", + ) + assert "do not include 'espidf'" in caplog.text + + +def test_source_download_not_implemented(): + with pytest.raises(NotImplementedError): + Source().download("x") + + +def test_gitsource_str_includes_ref_when_present(): + assert str(GitSource("http://git/repo.git", "main")) == "http://git/repo.git#main" + assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git" + + +def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): + monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) + dl_calls: list[list[str]] = [] + monkeypatch.setattr( + lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls) + ) + + def fake_extract(fileobj, path): + Path(path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(lib, "archive_extract_all", fake_extract) + + src = URLSource("http://example.test/lib.tar.gz") + out = src.download("mylib") + + assert (out / ".esphome_extracted").is_file() + assert dl_calls == [["http://example.test/lib.tar.gz"]] + + # The completion marker means a second download is skipped (cache hit). + out2 = src.download("mylib") + assert out2 == out + assert len(dl_calls) == 1 + + +def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): + registry = lib._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [{"name": "1.0.0", "files": [{}]}], + }, + ) + # A best version exists but none of its files is a compatible package. + monkeypatch.setattr( + registry, "pick_best_registry_version", lambda versions: versions[0] + ) + monkeypatch.setattr(registry, "pick_compatible_pkg_file", lambda files: None) + monkeypatch.setattr(lib, "_make_registry_client", lambda: registry) + + with pytest.raises(RuntimeError, match="No package file"): + _resolve_registry_version("owner", "pkg", set()) + + +def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): + """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" + + def fake_download(self, force=False, salt=""): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + self.path.mkdir(parents=True, exist_ok=True) + if self.name in properties: + (self.path / "library.properties").write_text(manifests[self.name]) + else: + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + monkeypatch.setattr( + lib, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + +def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch): + # A manifest provided as library.properties (Arduino style) instead of + # library.json must still be parsed and converted. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": "name=A\nversion=1.0\n"}, + properties=("esphome/A",), + ) + + emitted: list[ConvertedLibrary] = [] + top = convert_libraries( + [Library("esphome/A", "1.0.0", None)], _backend(emitted.append) + ) + + assert [c.name for c in top] == ["esphome/A"] + assert top[0].data["name"] == "A" + assert emitted[0].data["version"] == "1.0" + + +def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch): + # A dependency entry lacking a version is malformed and silently skipped. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}}, + ) + + # No version on the top-level spec exercises the "no requirement" path too. + top = convert_libraries([Library("esphome/A", None, None)], _backend()) + + assert top[0].dependencies == [] + + +def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch): + # If the git/archive URL probe (urlparse) raises on a malformed value, the + # dependency is still kept and treated as a plain version spec. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + # An unterminated IPv6 URL makes urlparse raise ValueError. + "dependencies": [{"name": "C", "version": "http://[::1"}], + }, + "C": {"name": "C"}, + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert [d.name for d in top[0].dependencies] == ["C"] + + +def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): + # A dependency that declares an incompatible platform is skipped (the + # top-level library still builds). + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "C", "version": "1.0", "platforms": ["avr"]}], + } + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert top[0].dependencies == [] From 8e23065b86798ad3a96216c05d9da76f4a1ac7ba Mon Sep 17 00:00:00 2001 From: alorente Date: Sun, 28 Jun 2026 13:14:05 +0200 Subject: [PATCH 194/343] [it8951] Add IT8951 e-paper controller support to epaper_spi (#15346) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Co-authored-by: Citric Li <37475446+limengdu@users.noreply.github.com> Co-authored-by: koosoli Co-authored-by: Cursor Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- CODEOWNERS | 1 + esphome/components/it8951/__init__.py | 1 + esphome/components/it8951/display.py | 433 +++++++ esphome/components/it8951/it8951.cpp | 1091 +++++++++++++++++ esphome/components/it8951/it8951.h | 373 ++++++ esphome/components/it8951/it8951_defs.h | 168 +++ .../components/it8951/test.esp32-s3-idf.yaml | 109 ++ tests/components/ld2450/common.h | 3 + 8 files changed, 2179 insertions(+) create mode 100644 esphome/components/it8951/__init__.py create mode 100644 esphome/components/it8951/display.py create mode 100644 esphome/components/it8951/it8951.cpp create mode 100644 esphome/components/it8951/it8951.h create mode 100644 esphome/components/it8951/it8951_defs.h create mode 100644 tests/components/it8951/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 70ad580e778..21121ff4762 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -266,6 +266,7 @@ esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core esphome/components/ir_rf_proxy/* @kbx81 +esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 esphome/components/json/* @esphome/core esphome/components/kamstrup_kmp/* @cfeenstra1024 diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py new file mode 100644 index 00000000000..7fc4ae2cd0e --- /dev/null +++ b/esphome/components/it8951/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py new file mode 100644 index 00000000000..51c5fc61181 --- /dev/null +++ b/esphome/components/it8951/display.py @@ -0,0 +1,433 @@ +""" +ESPHome configuration for the IT8951 e-paper controller. +""" + +from esphome import automation, core, pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation +import esphome.config_validation as cv +from esphome.config_validation import update_interval +from esphome.const import ( + CONF_BUSY_PIN, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DIMENSIONS, + CONF_ENABLE_PIN, + CONF_FULL_UPDATE_EVERY, + CONF_HEIGHT, + CONF_ID, + CONF_INVERT_COLORS, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODE, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_DURATION, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_SLEEP_WHEN_DONE, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_UPDATE_INTERVAL, + CONF_WIDTH, +) +from esphome.cpp_generator import RawExpression +from esphome.final_validate import full_config + +AUTO_LOAD = ["split_buffer"] +DEPENDENCIES = ["spi"] + +CONF_VCOM = "vcom" +CONF_VCOM_REGISTER = "vcom_register" +CONF_FORCE_TEMPERATURE = "force_temperature" +CONF_GRAYSCALE = "grayscale" +CONF_DITHERING = "dithering" +CONF_UPDATE_MODE = "update_mode" +CONF_USE_LEGACY_DPY_AREA = "use_legacy_dpy_area" + +# VCOM SET sub-command selectors. The IT8951 firmware accepts different +# values across panels; most respond to 0x0001, but a few — e.g. the Seeed +# reTerminal E1003 — only respond to 0x0002 and silently drop 0x0001. +VCOM_REGISTER_DEFAULT = 0x0001 +VCOM_REGISTER_ALT = 0x0002 +VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) + +it8951_ns = cg.esphome_ns.namespace("it8951") +IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) +IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) + +# Hardware waveform modes exposed to YAML. Strings are mapped to the C++ +# UpdateMode enum so the runtime can store the mode as a uint16_t rather +# than a std::string (avoiding a heap-resident member; see ESPHome +# CLAUDE.md "STL Container Guidelines"). "fast" and "full" are +# convenience aliases for DU and GC16 respectively. +UpdateMode = it8951_ns.enum("UpdateMode") +UPDATE_MODE_OPTIONS = { + "INIT": UpdateMode.UPDATE_MODE_INIT, + "DU": UpdateMode.UPDATE_MODE_DU, + "GC16": UpdateMode.UPDATE_MODE_GC16, + "GL16": UpdateMode.UPDATE_MODE_GL16, + "GLR16": UpdateMode.UPDATE_MODE_GLR16, + "GLD16": UpdateMode.UPDATE_MODE_GLD16, + "DU4": UpdateMode.UPDATE_MODE_DU4, + "A2": UpdateMode.UPDATE_MODE_A2, + "FAST": UpdateMode.UPDATE_MODE_DU, + "FULL": UpdateMode.UPDATE_MODE_GC16, +} +# Maps the YAML mode string directly to the C++ UpdateMode enum value, so the +# config option and the it8951.update action share one validator. +update_mode = cv.enum(UPDATE_MODE_OPTIONS, upper=True) + +# Transform flag values mirror the C++ TRANSFORM_* constants. +_TRANSFORM_NONE = 0 +_TRANSFORM_MIRROR_X = 1 +_TRANSFORM_MIRROR_Y = 2 +_TRANSFORM_SWAP_XY = 4 +_TRANSFORM_FLAGS = { + CONF_MIRROR_X: _TRANSFORM_MIRROR_X, + CONF_MIRROR_Y: _TRANSFORM_MIRROR_Y, + CONF_SWAP_XY: _TRANSFORM_SWAP_XY, +} + + +class IT8951Model: + """A specific board / panel preset for the IT8951 controller.""" + + models: dict[str, "IT8951Model"] = {} + + def __init__(self, name: str, **defaults): + name = name.upper() + self.name = name + self.defaults = defaults + IT8951Model.models[name] = self + + def get_default(self, key, fallback=None): + return self.defaults.get(key, fallback) + + def get_dimensions(self, config) -> tuple[int, int]: + # If dimensions are in config, use them; otherwise fall back to model defaults. + if CONF_DIMENSIONS in config: + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + return dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + return tuple(dimensions) + # Model must have defaults if dimensions not in config. + return self.get_default(CONF_WIDTH), self.get_default(CONF_HEIGHT) + + +# --- Model presets ---------------------------------------------------------- +# The generic model leaves dimensions and pin choices up to the user. +IT8951Model("it8951", vcom=2300, sleep_when_done=True, data_rate=12_000_000) + +IT8951Model( + "m5stack-m5paper", + width=960, + height=540, + busy_pin=27, + reset_pin=23, + cs_pin=15, + vcom=2300, + sleep_when_done=True, + data_rate=20_000_000, +) + +IT8951Model( + "seeed-reterminal-e1003", + width=1872, + height=1404, + busy_pin=13, + reset_pin=12, + cs_pin=10, + # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply + # (GPIO11). Driven high during setup so no separate power_supply is needed. + enable_pin=[21, 11], + vcom=1400, + # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET + # selector; using the default 0x0001 leaves VCOM unchanged and breaks + # grayscale waveforms (GC16/GL16) — INIT still works because it does + # not depend on VCOM accuracy. + vcom_register=VCOM_REGISTER_ALT, + # The reTerminal E1003 ships with on-die temperature sensing disabled, + # so the host must declare an operating temperature; otherwise the + # waveform LUT defaults to a value that produces no visible change + # for grayscale modes. + force_temperature=25, + sleep_when_done=False, + data_rate=20_000_000, + mirror_x=True, +) + +IT8951Model( + "seeed-ee03", + width=1872, + height=1404, + busy_pin=4, + reset_pin=38, + cs_pin=44, + vcom=1400, + sleep_when_done=False, + data_rate=4_000_000, +) + +# --------------------------------------------------------------------------- + +DIMENSION_SCHEMA = cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } +) + + +def _model_pin_option(model, key, schema): + default = model.get_default(key) + if default is None: + return cv.Required(key), schema + return cv.Optional(key, default=default), schema + + +def _model_schema(config): + model = IT8951Model.models[config[CONF_MODEL]] + has_default_dimensions = ( + model.get_default(CONF_WIDTH) is not None + and model.get_default(CONF_HEIGHT) is not None + ) + dimensions_key = ( + cv.Optional( + CONF_DIMENSIONS, + default={ + CONF_WIDTH: model.get_default(CONF_WIDTH), + CONF_HEIGHT: model.get_default(CONF_HEIGHT), + }, + ) + if has_default_dimensions + else cv.Required(CONF_DIMENSIONS) + ) + + schema = display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_mode="MODE0", + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + ) + ).extend( + { + cv.GenerateID(): cv.declare_id(IT8951Display), + cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True, space="-"), + cv.Optional(CONF_ROTATION, default=0): validate_rotation, + cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): update_interval, + cv.Optional(CONF_FULL_UPDATE_EVERY, default=30): cv.int_range(1, 255), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional( + CONF_INVERT_COLORS, default=model.get_default(CONF_INVERT_COLORS, False) + ): cv.boolean, + cv.Optional( + CONF_SLEEP_WHEN_DONE, + default=model.get_default(CONF_SLEEP_WHEN_DONE, False), + ): cv.boolean, + # Pixel format: true = 4bpp grayscale, false = packed 1bpp + # monochrome. Monochrome halves the framebuffer and enables fast DU + # partial refreshes; grayscale gives 16 levels but always uses GC16. + cv.Optional( + CONF_GRAYSCALE, default=model.get_default(CONF_GRAYSCALE, True) + ): cv.boolean, + # Monochrome only: ordered-dither pale colours so they render as + # visible stipple. Disable for a crisp hard black/white threshold + # (better for purely black/white text). No effect in grayscale mode. + cv.Optional( + CONF_DITHERING, default=model.get_default(CONF_DITHERING, True) + ): cv.boolean, + cv.Optional( + CONF_VCOM, default=model.get_default(CONF_VCOM, 2300) + ): cv.int_range(0, 5000), + cv.Optional( + CONF_VCOM_REGISTER, + default=model.get_default(CONF_VCOM_REGISTER, VCOM_REGISTER_DEFAULT), + ): cv.one_of(*VCOM_REGISTER_OPTIONS, int=True), + **( + { + cv.Optional( + CONF_FORCE_TEMPERATURE, + default=model.get_default(CONF_FORCE_TEMPERATURE), + ): cv.int_range(min=-40, max=85) + } + if model.get_default(CONF_FORCE_TEMPERATURE) is not None + else {} + ), + cv.Optional( + CONF_USE_LEGACY_DPY_AREA, + default=model.get_default(CONF_USE_LEGACY_DPY_AREA, False), + ): cv.boolean, + cv.Optional(CONF_UPDATE_MODE): update_mode, + # One or more GPIOs driven high during setup to power on the panel + # (e.g. board power-enable rails), before reset and init. + cv.Optional( + CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, []) + ): cv.ensure_list(pins.gpio_output_pin_schema), + cv.Optional(CONF_RESET_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=core.TimePeriod(milliseconds=500)), + ), + dimensions_key: DIMENSION_SCHEMA, + } + ) + + # Pin options: required if the model doesn't supply a default. + pin_specs = ( + (CONF_BUSY_PIN, pins.gpio_input_pin_schema), + (CONF_RESET_PIN, pins.gpio_output_pin_schema), + (CONF_CS_PIN, pins.gpio_output_pin_schema), + ) + pin_extra = {} + for key, schema_value in pin_specs: + opt, sv = _model_pin_option(model, key, schema_value) + pin_extra[opt] = sv + return schema.extend(pin_extra) + + +def _customise_schema(config): + config = cv.Schema( + { + cv.Required(CONF_MODEL): cv.one_of( + *IT8951Model.models, upper=True, space="-" + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + + model_config = _model_schema(config)(config) + + model = IT8951Model.models[config[CONF_MODEL].upper()] + width, height = model.get_dimensions(model_config) + + display.add_metadata( + model_config[CONF_ID], + width, + height, + # Rotation is applied per-pixel in draw_pixel_at at no extra cost, so we + # advertise hardware rotation: LVGL routes its rotation to the driver via + # set_rotation rather than rotating the framebuffer in software. + has_hardware_rotation=True, + has_writer=any( + model_config.get(key) + for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD) + ), + # Report the configured rotation so LVGL can detect (and reject) a + # rotation set in the display config instead of the LVGL config. + rotation=model_config.get(CONF_ROTATION, 0), + # The IT8951 snaps partial display refreshes to a 32-pixel X boundary + # (see prepare_update_region_), so have LVGL round its redraw areas to + # 32px too — this keeps flush rectangles aligned with what the panel + # actually refreshes and avoids redundant re-rounding/over-draw. + draw_rounding=32, + ) + + return model_config + + +CONFIG_SCHEMA = _customise_schema + + +def _final_validate(config): + # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. + spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( + config + ) + + global_config = full_config.get() + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if CONF_LAMBDA not in config and CONF_PAGES not in config: + if LVGL_DOMAIN in global_config: + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("never") + else: + config[CONF_SHOW_TEST_CARD] = True + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + model = IT8951Model.models[config[CONF_MODEL]] + width, height = model.get_dimensions(config) + + var = cg.new_Pvariable(config[CONF_ID], model.name, width, height) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=False) + + if lambda_config := config.get(CONF_LAMBDA): + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + if busy_pin := config.get(CONF_BUSY_PIN): + cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + if enable_pins := config.get(CONF_ENABLE_PIN): + cg.add( + var.set_enable_pins( + [await cg.gpio_pin_expression(pin) for pin in enable_pins] + ) + ) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) + if (reset_duration := config.get(CONF_RESET_DURATION)) is not None: + cg.add(var.set_reset_duration(reset_duration)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) + if config.get(CONF_SLEEP_WHEN_DONE): + cg.add(var.set_sleep_when_done(True)) + cg.add(var.set_vcom(config[CONF_VCOM])) + cg.add(var.set_vcom_register(config[CONF_VCOM_REGISTER])) + if CONF_FORCE_TEMPERATURE in config: + cg.add(var.set_force_temperature(config[CONF_FORCE_TEMPERATURE])) + if config.get(CONF_USE_LEGACY_DPY_AREA): + cg.add(var.set_use_legacy_dpy_area(True)) + cg.add(var.set_grayscale(config[CONF_GRAYSCALE])) + cg.add(var.set_dithering(config[CONF_DITHERING])) + if (mode := config.get(CONF_UPDATE_MODE)) is not None: + cg.add(var.set_update_mode(mode)) + + transform = config.get( + CONF_TRANSFORM, + { + CONF_MIRROR_X: model.get_default(CONF_MIRROR_X), + CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y), + }, + ) + + transform_value = sum( + flag for key, flag in _TRANSFORM_FLAGS.items() if transform.get(key) + ) + if transform_value: + cg.add(var.set_transform(RawExpression(str(transform_value)))) + + +@automation.register_action( + "it8951.update", + IT8951UpdateAction, + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(IT8951Display), + cv.Optional(CONF_MODE): cv.templatable(update_mode), + } + ), + synchronous=True, +) +async def it8951_update_action_to_code(config, action_id, template_arg, args): + display_var = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, display_var) + if mode := config.get(CONF_MODE): + mode = await cg.templatable(mode, args, UpdateMode) + cg.add(var.set_mode(mode)) + return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp new file mode 100644 index 00000000000..cc2bddeda7b --- /dev/null +++ b/esphome/components/it8951/it8951.cpp @@ -0,0 +1,1091 @@ +#include "it8951.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::it8951 { + +static const char *const TAG = "it8951"; + +// Soft cap for time spent in a single XFER_ROWS Op so we yield back to the +// loop within one tick budget. +static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; + +// --- Loop / scheduling ------------------------------------------------------- + +void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_back(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +void IT8951Display::prepend_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_front(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +bool IT8951Display::is_busy_() const { + // IT8951 Hardware Ready (HW_RDY): HIGH = ready, LOW = busy. + return !this->busy_pin_->digital_read(); +} + +void IT8951Display::loop() { + const uint32_t now = millis(); + if (static_cast(now - this->delay_until_) < 0) + return; + + // Nothing queued — either the current phase has more work to enqueue, or + // we're done. + if (this->queue_.empty()) { + if (this->phase_ == Phase::IDLE) { + this->disable_loop(); + return; + } + this->advance_phase_(); + if (this->queue_.empty()) + return; + } + + // Gate SPI ops on HW_RDY. GPIO/DELAY ops run unconditionally — they're how + // we get the controller out of a stuck-busy state in the first place + // (e.g. during reset, HW_RDY is undefined/low until ROM boot completes). + Op queued_op = this->queue_.front(); + const bool needs_hardware_ready = queued_op.type != OpType::GPIO_RESET_LOW && + queued_op.type != OpType::GPIO_RESET_HIGH && queued_op.type != OpType::DELAY_MS; + if (needs_hardware_ready && this->is_busy_()) { + // Signed elapsed: any pending DELAY_MS or scheduled work in the near + // future shows up as <= 0 elapsed and won't trigger a false timeout. + const int32_t elapsed = static_cast(now - this->phase_started_at_); + ESP_LOGV(TAG, "HW_RDY is LOW (busy) in phase %u, elapsed=%" PRId32 "ms", static_cast(this->phase_), + elapsed); + if (elapsed > static_cast(BUSY_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Busy timeout (%" PRIu32 "ms) in phase %u, recovering", elapsed, + static_cast(this->phase_)); + this->recover_(); + } + return; + } + + this->queue_.pop_front(); + this->process_op_(queued_op); +} + +void IT8951Display::process_op_(const Op &op) { + ESP_LOGV(TAG, "Processing op type=%u a=0x%04X b=0x%04X", static_cast(op.type), op.a, op.b); + switch (op.type) { + case OpType::CMD: + this->spi_cmd_(op.a); + break; + case OpType::WRITE_W: + this->spi_write_word_(op.a); + break; + case OpType::WRITE_REG: + this->spi_write_reg_(op.a, op.b); + break; + case OpType::READ_DEV_INFO: + this->spi_read_dev_info_(); + break; + case OpType::READ_WORD: + this->read_result_ = this->spi_read_word_(); + break; + case OpType::CHECK_LUT_IDLE: + this->op_check_lut_idle_(); + break; + case OpType::SET_1BPP: + this->op_set_1bpp_(); + break; + case OpType::XFER_LISAR: + this->op_xfer_lisar_(); + break; + case OpType::XFER_AREA_CMD: + this->spi_cmd_(TCON_LD_IMG_AREA); + break; + case OpType::XFER_AREA_ARGS: + this->op_xfer_area_args_(); + break; + case OpType::XFER_ROWS: + // Stream rows into the single open LD_IMG_AREA load. The load stays open + // across loop iterations (CS toggles between bursts, matching the + // reference driver), so a partial slice just re-queues another XFER_ROWS + // pass to resume; only when all rows are sent do we close it with one + // LD_IMG_END. This avoids an LD_IMG_END / LD_IMG_AREA round-trip per slice. + if (this->op_xfer_rows_()) { + this->enqueue_(OpType::XFER_AREA_END); + } else { + this->enqueue_(OpType::XFER_ROWS); + } + break; + case OpType::XFER_AREA_END: + this->op_xfer_area_end_(); + break; + case OpType::DPY_BUF_CMD: + // Some panel firmwares (notably Seeed reTerminal E1003) silently drop + // I80_CMD_DPY_BUF_AREA (0x0037) — the LUT engine never starts and the + // host eventually times out after ~12s. Fall back to the basic + // I80_CMD_DPY_AREA (0x0034) for those panels; the buffer address is + // already programmed via LISAR during the transfer phase. + this->spi_cmd_(this->use_legacy_dpy_area_ ? I80_CMD_DPY_AREA : I80_CMD_DPY_BUF_AREA); + break; + case OpType::DPY_BUF_ARGS: + this->op_dpy_buf_args_(); + break; + case OpType::GPIO_RESET_LOW: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(false); + break; + case OpType::GPIO_RESET_HIGH: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(true); + break; + case OpType::DELAY_MS: + this->delay_until_ = millis() + op.a; + break; + } +} + +void IT8951Display::set_phase_(Phase next) { + ESP_LOGV(TAG, "Phase %u -> %u", static_cast(this->phase_), static_cast(next)); + // Run the loop continuously for the whole active sequence, returning to normal + // throttling only at IDLE. Each queued op is processed one per loop iteration, + // so at the default ~16ms loop interval the dozens of small ops in the refresh + // and restore phases (register polls, 1bpp enable/restore, DPY) would dominate + // a partial update's latency. The LUT-idle polls are DELAY_MS-paced, so this + // doesn't hammer SPI — it only spends a little extra CPU during the (short, + // infrequent) update instead of sleeping between ops. start()/stop() are + // idempotent, so driving them off the transition is safe. + if (next == Phase::IDLE) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + } + this->phase_ = next; + this->phase_started_at_ = millis(); +} + +void IT8951Display::advance_phase_() { + switch (this->phase_) { + case Phase::IDLE: + if (this->initialised_ && this->update_pending_) { + this->update_pending_ = false; + this->active_mode_ = this->pending_update_mode_; + this->update_started_at_ = millis(); + this->set_phase_(Phase::UPDATE_PREPARE); + this->advance_phase_(); + } else { + this->disable_loop(); + } + break; + + case Phase::INIT_RESET: + this->set_phase_(Phase::INIT_DEV_INFO); + this->enqueue_init_dev_info_(); + break; + + case Phase::INIT_DEV_INFO: + if (this->dev_info_.panel_width == 0 || this->dev_info_.panel_width > 2048 || this->dev_info_.panel_height == 0 || + this->dev_info_.panel_height > 2048 || this->dev_info_.panel_width == 0xFFFF || + this->dev_info_.panel_height == 0xFFFF) { + if (++this->dev_info_attempts_ < 5) { + ESP_LOGW(TAG, "DevInfo attempt %u returned invalid data (W=%u H=%u), retrying...", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + // Give the controller more time, then re-read. + this->enqueue_(OpType::DELAY_MS, 100); + this->enqueue_init_dev_info_(); + return; + } + ESP_LOGE(TAG, "DevInfo invalid after %u attempts (W=%u H=%u)", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("Failed to read IT8951 device info")); + this->set_phase_(Phase::IDLE); + return; + } + + if (this->dev_info_.panel_width != this->width_ || this->dev_info_.panel_height != this->height_) { + ESP_LOGE(TAG, "Panel dimension mismatch: configured=%ux%u, DevInfo=%ux%u. Check model/dimensions settings.", + this->width_, this->height_, this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("IT8951 panel dimensions do not match DevInfo")); + this->set_phase_(Phase::IDLE); + return; + } + + this->dev_info_attempts_ = 0; + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + this->img_buf_addr_l_ = this->dev_info_.img_buf_addr_l; + this->img_buf_addr_h_ = this->dev_info_.img_buf_addr_h; + ESP_LOGI(TAG, "DevInfo: %ux%u, ImgBuf 0x%04X%04X", this->width_, this->height_, this->img_buf_addr_h_, + this->img_buf_addr_l_); + this->set_phase_(Phase::INIT_VCOM); + this->enqueue_init_vcom_(); + break; + + case Phase::INIT_VCOM: + this->set_phase_(Phase::INIT_TEMP); + if (this->force_temperature_set_) { + this->enqueue_init_temp_(); + } else { + this->advance_phase_(); + } + break; + + case Phase::INIT_TEMP: + this->set_phase_(Phase::INIT_DONE); + this->advance_phase_(); + break; + + case Phase::INIT_DONE: + if (this->configured_data_rate_ != 0 && this->configured_data_rate_ != this->data_rate_) { + this->spi_teardown(); + this->set_data_rate(this->configured_data_rate_); + this->spi_setup(); + } + this->initialised_ = true; + this->recovery_attempts_ = 0; + ESP_LOGCONFIG(TAG, "IT8951 setup complete"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + + case Phase::UPDATE_PREPARE: { + this->do_update_(); + UpdateMode mode = this->active_mode_; + if (!this->prepare_update_region_(mode)) { + ESP_LOGD(TAG, "Nothing to update"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + return; + } + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_TRANSFER); + this->enqueue_update_transfer_(); + break; + } + + case Phase::UPDATE_TRANSFER: + this->set_phase_(Phase::UPDATE_REFRESH); + this->enqueue_update_refresh_(); + break; + + case Phase::UPDATE_REFRESH: + // Fire-and-forget: don't block here waiting for the refresh to complete. + // The next update's pre-display LUT-idle poll (and the HW_RDY-gated + // TCON_SLEEP) wait as needed, so the refresh time stays off this update's + // critical path. The 1bpp display mode is left enabled rather than + // restored after every update: on a monochrome display every update + // (DU partials and the periodic GC16 cleans) runs in 1bpp mode, so the + // bit never needs clearing — and clearing it required a full + // refresh-length LUT-idle wait. + this->set_phase_(Phase::UPDATE_SLEEP); + this->enqueue_update_sleep_(); + break; + + case Phase::UPDATE_SLEEP: + ESP_LOGV(TAG, "Update took %" PRIu32 "ms (mode=%u area=%ux%u@%u,%u)", millis() - this->update_started_at_, + static_cast(this->active_mode_), this->area_w_, this->area_h_, this->area_x_, this->area_y_); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + } +} + +// --- Setup ------------------------------------------------------------------- + +void IT8951Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up IT8951..."); + this->configured_data_rate_ = this->data_rate_; + this->data_rate_ = SPI_PROBE_FREQUENCY; + this->spi_setup(); + + // Power on the panel before reset and the init handshake. + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + if (this->busy_pin_ != nullptr) { + this->busy_pin_->setup(); + } + + this->update_effective_transform_(); + this->reset_dirty_region_(); + + // Allocate the framebuffer now: its size is fixed by the configured pixel + // format and dimensions, so there's no need to defer to the async controller + // init. LVGL (and other writers) can push pixels via draw_pixels_at as soon + // as the component is set up — before init completes — and without a buffer + // those writes would dereference a null pointer and crash. + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + RAMAllocator allocator{}; + this->buffer_ = allocator.allocate(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + return; + } + // The allocator does not zero memory; start blank (white) so undrawn regions + // (e.g. with auto_clear disabled) don't show garbage on the first update. + this->fill(Color::WHITE); + + // Kick off async init via the queue. Reset pulse + boot delay + wake + + // packed-write enable; everything blocking lives as DELAY_MS Ops gated by + // the loop scheduler. + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->enable_loop(); +} + +void IT8951Display::on_safe_shutdown() { + // Best-effort synchronous sleep — runs during shutdown so we don't queue. + this->spi_cmd_(TCON_SLEEP); +} + +// --- Init op enqueuers ------------------------------------------------------- + +void IT8951Display::enqueue_init_reset_() { + // A reset (including recovery) re-runs SYS_RUN below, so the controller is + // awake once this sequence completes. + this->asleep_ = false; + // Reset pulse: high -> low (reset_duration) -> high -> wait for ROM boot. + this->enqueue_(OpType::GPIO_RESET_HIGH); + this->enqueue_(OpType::GPIO_RESET_LOW); + this->enqueue_(OpType::DELAY_MS, static_cast(this->reset_duration_)); + this->enqueue_(OpType::GPIO_RESET_HIGH); + // SPI ROM boot. HW_RDY gating in loop() handles the actual wait, but a small + // floor avoids hammering SPI before HW_RDY has settled high. 300ms matches + // what most IT8951 reference drivers use for safety. + this->enqueue_(OpType::DELAY_MS, 300); + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->enqueue_(OpType::CMD, TCON_REG_WR); // packed write mode + this->enqueue_(OpType::WRITE_REG, I80CPCR, 0x0001); +} + +void IT8951Display::enqueue_init_dev_info_() { + // CMD triggers the controller to prepare DevInfo. HW_RDY drops while it works. + // The loop-level HW_RDY gate non-blockingly waits before dispatching READ_DEV_INFO. + this->enqueue_(OpType::CMD, I80_CMD_GET_DEV_INFO); + this->enqueue_(OpType::READ_DEV_INFO); +} + +void IT8951Display::enqueue_init_vcom_() { + // Always write configured VCOM. The IT8951 stores it in OTP-backed RAM; + // rewriting the same value is harmless. The VCOM SET selector is + // panel-specific (see I80_CMD_VCOM_WRITE / I80_CMD_VCOM_WRITE_ALT in + // it8951_defs.h) and is supplied via the model preset. + this->enqueue_(OpType::CMD, I80_CMD_VCOM); + this->enqueue_(OpType::WRITE_W, this->vcom_register_); + this->enqueue_(OpType::WRITE_W, this->vcom_); +} + +void IT8951Display::enqueue_init_temp_() { + // Force panel temperature (in degrees C) so the controller selects the + // correct waveform LUT. Some panels (e.g. Seeed reTerminal E1003) ship + // with auto-temperature disabled and rely on the host to declare the + // operating temperature; without this, grayscale waveforms run against + // a mismatched LUT and pixels do not visibly change even though the LUT + // engine completes a full cycle. + this->enqueue_(OpType::CMD, I80_CMD_FORCE_TEMP); + this->enqueue_(OpType::WRITE_W, I80_CMD_FORCE_TEMP_WRITE); + this->enqueue_(OpType::WRITE_W, static_cast(this->force_temperature_)); +} + +// --- Update op enqueuers ----------------------------------------------------- + +void IT8951Display::enqueue_update_transfer_() { + // If the controller was put to sleep after the previous update, wake it + // before touching the display engine. TCON_SLEEP gates off all clocks; a + // register read (e.g. the LUTAFSR poll in UPDATE_REFRESH) returns a frozen + // value while asleep, so without this the next update stalls forever in + // op_check_lut_idle_(). SRAM/registers (packed-write mode, VCOM, LUT) are + // retained across sleep, so SYS_RUN + a short settle is all that's needed. + if (this->asleep_) { + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->asleep_ = false; + } + this->transfer_row_ = 0; + // Open a single LD_IMG_AREA load for the whole region. XFER_ROWS streams into + // it across as many time-sliced passes as needed and emits the one matching + // LD_IMG_END when the last row is sent (see the XFER_ROWS handler). + this->enqueue_(OpType::XFER_LISAR); + this->enqueue_(OpType::XFER_AREA_CMD); + this->enqueue_(OpType::XFER_AREA_ARGS); + this->enqueue_(OpType::XFER_ROWS); +} + +void IT8951Display::enqueue_update_refresh_() { + ESP_LOGV(TAG, "Enqueueing refresh ops: grayscale=%u", this->grayscale_); + // Poll LUT idle: CMD(REG_RD) → WRITE_W(LUTAFSR) → READ_WORD → CHECK_LUT_IDLE + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, LUTAFSR); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::CHECK_LUT_IDLE); + if (!this->grayscale_) { + // Read UP1SR+2: CMD(REG_RD) → WRITE_W(UP1SR+2) → READ_WORD → SET_1BPP + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, static_cast(UP1SR + 2)); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::SET_1BPP); + } + this->enqueue_(OpType::DPY_BUF_CMD); + this->enqueue_(OpType::DPY_BUF_ARGS); +} + +void IT8951Display::enqueue_update_sleep_() { + if (this->sleep_when_done_) { + this->enqueue_(OpType::CMD, TCON_SLEEP); + // Remember that the controller is now asleep so the next update wakes it + // (see enqueue_update_transfer_) before polling any register. + this->asleep_ = true; + } +} + +// --- SPI primitives ---------------------------------------------------------- +// +// IT8951 SPI protocol: no DC pin. 16-bit preamble word identifies whether +// the transaction is command (0x6000), write-data (0x0000), or read-data +// (0x1000). +// +// All ops are fully non-blocking at the loop level. The loop-level HW_RDY gate +// guarantees the controller is ready before any op is dispatched. +// +// Within a single CS-asserted transaction, the IT8951 requires HW_RDY to be +// checked after the preamble word before sending the first data word. This +// is a hardware protocol requirement — the controller needs a few clock +// cycles to latch the preamble and configure its internal bus direction. +// In practice this completes in <1µs for write ops; we use a short spin +// (max ~50µs) that never triggers under normal operation. + +static constexpr uint32_t INTRA_CS_READY_TIMEOUT_US = 50; + +static inline void wait_for_hardware_ready(GPIOPin *busy_pin) { + if (busy_pin == nullptr) + return; + uint32_t waited = 0; + while (!busy_pin->digital_read()) { + if (waited >= INTRA_CS_READY_TIMEOUT_US) + return; + delayMicroseconds(1); + waited += 1; + } +} + +void IT8951Display::spi_cmd_(uint16_t cmd) { + this->enable(); + this->write_byte16(PACKET_TYPE_CMD); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(cmd); + this->disable(); +} + +void IT8951Display::spi_write_word_(uint16_t value) { + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_reg_(uint16_t addr, uint16_t value) { + // Single CS transaction: WRITE preamble + addr + value. + // Caller must have already sent CMD(TCON_REG_WR) as a prior op. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(addr); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_args_(const uint16_t *args, uint16_t count) { + // Single CS transaction: WRITE preamble + N data words. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + for (uint16_t i = 0; i < count; i++) + this->write_byte16(args[i]); + this->disable(); +} + +uint16_t IT8951Display::spi_read_word_() { + // Single CS read transaction. HW_RDY was confirmed HIGH by the loop gate + // before this op was dispatched, so data is ready. + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy — provides clock cycles for controller + wait_for_hardware_ready(this->busy_pin_); + // Read byte-by-byte: a 2-byte transfer_array can lose the low byte on + // ESP-IDF SPI DMA due to 4-byte alignment requirements. + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + this->disable(); + return encode_uint16(hi, lo); +} + +void IT8951Display::spi_read_dev_info_() { + // Read DevInfo struct. The CMD(GET_DEV_INFO) was already sent as a prior op, + // and the loop HW_RDY gate waited for the controller to prepare data. + std::memset(&this->dev_info_, 0, sizeof(this->dev_info_)); + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy + wait_for_hardware_ready(this->busy_pin_); + auto *words = reinterpret_cast(&this->dev_info_); + constexpr uint32_t word_count = sizeof(this->dev_info_) / sizeof(uint16_t); + for (uint32_t i = 0; i < word_count; i++) { + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + words[i] = encode_uint16(hi, lo); + } + this->disable(); +} + +// --- Compound Ops ------------------------------------------------------------ + +void IT8951Display::op_xfer_lisar_() { + // Set image-buffer target address. Two register writes = 4 CS transactions. + // Push to FRONT in reverse order so they execute before the rest of the queue. + this->prepend_(OpType::WRITE_REG, LISAR, this->img_buf_addr_l_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, static_cast(LISAR + 2), this->img_buf_addr_h_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +void IT8951Display::op_xfer_area_args_() { + // Single CS transaction: WRITE preamble + 5 area-parameter words describing + // the full update region. Sent once when the load is opened (transfer_row_ is + // 0); XFER_ROWS then streams every row into this one area. + uint16_t args[5]; + if (this->grayscale_) { + args[0] = static_cast((LDIMG_B_ENDIAN << 8) | (PIXEL_4BPP << 4)); + args[1] = this->area_x_; + args[2] = this->area_y_; + args[3] = this->area_w_; + args[4] = this->area_h_; + } else { + // Monochrome is loaded via the 8bpp-packed trick: x and width are expressed + // in bytes (8 pixels each) and the controller unpacks one bit per pixel. + args[0] = static_cast((LDIMG_L_ENDIAN << 8) | (PIXEL_8BPP << 4)); + args[1] = static_cast(this->area_x_ / 8); + args[2] = this->area_y_; + args[3] = static_cast(this->area_w_ / 8); + args[4] = this->area_h_; + } + this->spi_write_args_(args, 5); +} + +void IT8951Display::op_xfer_area_end_() { this->spi_cmd_(TCON_LD_IMG_END); } + +bool IT8951Display::op_xfer_rows_() { + const uint32_t start_time = millis(); + const uint16_t area_y = this->area_y_; + const uint16_t area_h = this->area_h_; + + // Bytes per source row, and the byte offset of area_x within a row, in the + // framebuffer's native packing. These match the per-row byte count the + // controller expects from op_xfer_area_args_: area_w/2 for 4bpp grayscale, + // area_w/8 for the 1bpp-packed monochrome trick. area_x / area_w are + // 16-pixel aligned (see prepare_update_region_), so both divisions are exact. + const uint16_t bytes_per_row = + this->grayscale_ ? static_cast(this->area_w_ >> 1) : static_cast(this->area_w_ >> 3); + const uint16_t row_x_bytes = + this->grayscale_ ? static_cast(this->area_x_ >> 1) : static_cast(this->area_x_ >> 3); + + // Single CS write transaction — HW_RDY was confirmed high by the loop gate. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + + // Each source row is a contiguous slice of the framebuffer in both formats — + // the buffer already holds the wire bytes — so stream it straight to SPI with + // no per-pixel packing or temporary buffer. + while (this->transfer_row_ < area_h) { + const uint32_t offset = (static_cast(area_y) + this->transfer_row_) * this->row_width_ + row_x_bytes; + this->write_array(&this->buffer_[offset], bytes_per_row); + this->transfer_row_++; + if (millis() - start_time >= MAX_TRANSFER_TIME_MS) + break; + } + + this->disable(); + return this->transfer_row_ >= area_h; +} + +void IT8951Display::op_dpy_buf_args_() { + // I80_CMD_DPY_BUF_AREA (0x0037) takes 7 args (with explicit buffer addr). + // I80_CMD_DPY_AREA (0x0034) takes 5 args; the buffer address is taken + // from LISAR which we program during the transfer phase, so this is safe. + if (this->use_legacy_dpy_area_) { + const uint16_t args[5] = { + this->area_x_, this->area_y_, this->area_w_, this->area_h_, static_cast(this->active_mode_), + }; + this->spi_write_args_(args, 5); + return; + } + const uint16_t args[7] = { + this->area_x_, + this->area_y_, + this->area_w_, + this->area_h_, + static_cast(this->active_mode_), + this->img_buf_addr_l_, + this->img_buf_addr_h_, + }; + this->spi_write_args_(args, 7); +} + +void IT8951Display::op_check_lut_idle_() { + ESP_LOGV(TAG, "Checking LUT idle, read_result_=0x%04X", this->read_result_); + // read_result_ holds LUTAFSR value from the preceding READ_WORD op. + if (this->read_result_ != 0) { + // LUT still busy — re-enqueue the full read sequence after a short delay. + this->prepend_(OpType::CHECK_LUT_IDLE, 0, 0); + this->prepend_(OpType::READ_WORD, 0, 0); + this->prepend_(OpType::WRITE_W, LUTAFSR, 0); + this->prepend_(OpType::CMD, TCON_REG_RD, 0); + this->prepend_(OpType::DELAY_MS, 5, 0); + } +} + +void IT8951Display::op_set_1bpp_() { + // read_result_ holds UP1SR+2 value. Set bit 2 and write back, then set BGVR. + // Push to FRONT in reverse order so they execute before DPY_BUF_CMD/ARGS + // that are already in the queue. + const uint16_t modified = static_cast(this->read_result_ | (1U << 2)); + this->prepend_(OpType::WRITE_REG, BGVR, 0xFF00); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, UP1SR + 2, modified); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +// --- Update prep / public API ------------------------------------------------ + +bool IT8951Display::prepare_update_region_(UpdateMode &mode) { + this->partial_update_count_++; + const bool full_update = this->partial_update_count_ >= this->full_update_every_; + if (full_update) { + this->partial_update_count_ = 0; + mode = UPDATE_MODE_GC16; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } else { + // Align the partial region's X extent to 32 pixels. The IT8951's partial + // display refresh snaps the X start/width to a 32-pixel boundary (the panel + // source driver fetches 32-pixel chunks); refreshing a region whose X is + // only 16-aligned makes the panel snap it down to the previous boundary, + // shifting that update ~16px to the left. 32-alignment also satisfies the + // load constraints (4bpp X must be a multiple of 4; the 8bpp-packed mono + // load needs x/8 even, i.e. X a multiple of 16). + this->x_low_ &= 0xFFE0; + uint16_t temp_max = this->x_high_ > 0 ? static_cast(this->x_high_ - 1) : 0; + temp_max = static_cast(temp_max | 0x001F); + if (temp_max >= this->width_) + temp_max = static_cast(this->width_ - 1); + this->x_high_ = static_cast(temp_max + 1); + } + + if (this->x_high_ <= this->x_low_ || this->y_high_ <= this->y_low_) { + this->reset_dirty_region_(); + return false; + } + + const uint16_t x = this->x_low_; + const uint16_t y = this->y_low_; + const uint16_t width = static_cast(this->x_high_ - this->x_low_); + const uint16_t height = static_cast(this->y_high_ - this->y_low_); + + if (x >= this->width_ || y >= this->height_ || (x + width) > this->width_ || (y + height) > this->height_) { + ESP_LOGE(TAG, "Dirty region (%u,%u %ux%u) out of bounds", x, y, width, height); + this->reset_dirty_region_(); + return false; + } + + this->area_x_ = x; + this->area_y_ = y; + this->area_w_ = width; + this->area_h_ = height; + this->transfer_row_ = 0; + + // On non-full updates, downgrade monochrome frames from the full, flashy GC16 + // clear to DU — a fast, low-flash absolute waveform — so full_update_every + // buys cheaper refreshes between the periodic GC16 cleans that clear + // accumulated ghosting. + // + // Grayscale frames are deliberately left on GC16: every reduced grayscale + // waveform this controller exposes (the non-flashing GL family GL16/GLR16/ + // GLD16, and the 4-tone DU4) renders incorrectly on the supported panels — + // a white background is driven to grey rather than staying white. GC16 is the + // only waveform that reproduces grayscale faithfully, so we keep it. + // + // An explicitly configured non-GC16 update_mode is honoured as-is. + if (!full_update && mode == UPDATE_MODE_GC16 && !this->grayscale_) + mode = UPDATE_MODE_DU; + + this->reset_dirty_region_(); + + ESP_LOGV(TAG, "Update: %ux%u@%u,%u mode=%u (%s)", width, height, x, y, static_cast(mode), + this->grayscale_ ? "grayscale" : "mono"); + return true; +} + +void IT8951Display::reset_dirty_region_() { + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; +} + +void IT8951Display::start_update_(UpdateMode mode) { + if (this->phase_ == Phase::IDLE && this->initialised_) { + this->update_started_at_ = millis(); + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_PREPARE); + this->enable_loop(); + this->advance_phase_(); + } else { + // Coalesce: latest pending mode wins. + this->update_pending_ = true; + this->pending_update_mode_ = mode; + this->enable_loop(); + } +} + +void IT8951Display::update() { + if (!this->is_ready()) + return; + if (this->default_update_mode_ != UPDATE_MODE_NONE) { + this->start_update_(this->default_update_mode_); + return; + } + this->start_update_(UPDATE_MODE_GC16); +} + +void IT8951Display::update_mode(UpdateMode mode) { + if (!this->is_ready()) + return; + if (mode == UPDATE_MODE_NONE) { + ESP_LOGW(TAG, "Unknown update mode"); + return; + } + this->start_update_(mode); +} + +// --- Recovery ---------------------------------------------------------------- + +void IT8951Display::recover_() { + if (++this->recovery_attempts_ > 3) { + ESP_LOGE(TAG, "Recovery failed after %u attempts; giving up. Check BUSY pin wiring and power.", + this->recovery_attempts_); + this->mark_failed(LOG_STR("IT8951 recovery exhausted")); + this->queue_.clear(); + this->set_phase_(Phase::IDLE); + this->disable_loop(); + return; + } + ESP_LOGW(TAG, "Recovering (attempt %u): hardware-resetting controller (was in phase %u)", this->recovery_attempts_, + static_cast(this->phase_)); + this->queue_.clear(); + this->update_pending_ = false; + this->transfer_row_ = 0; + this->initialised_ = false; + this->dev_info_attempts_ = 0; + + // Drop SPI clock back to the safe probe rate for the re-init handshake. + if (this->configured_data_rate_ != 0 && this->data_rate_ != SPI_PROBE_FREQUENCY) { + this->spi_teardown(); + this->set_data_rate(SPI_PROBE_FREQUENCY); + this->spi_setup(); + } + + // Force a full redraw on next opportunity. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->update_pending_ = true; + this->pending_update_mode_ = UPDATE_MODE_GC16; + this->enable_loop(); +} + +// --- Coordinate transform ---------------------------------------------------- + +void IT8951Display::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_MIRROR_Y | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + +void IT8951Display::apply_transform_(int &x, int &y) const { + if (this->effective_transform_ & TRANSFORM_SWAP_XY) + std::swap(x, y); + if (this->effective_transform_ & TRANSFORM_MIRROR_X) + x = this->width_ - x - 1; + if (this->effective_transform_ & TRANSFORM_MIRROR_Y) + y = this->height_ - y - 1; +} + +bool IT8951Display::rotate_coordinates_(int &x, int &y) { + if (!this->get_clipping().inside(x, y)) + return false; + this->apply_transform_(x, y); + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); + return true; +} + +// --- Color / drawing --------------------------------------------------------- + +static uint8_t quantize_8bit_to_nibble(uint8_t value) { + uint8_t nibble = static_cast((static_cast(value) + 8) >> 4); + return nibble > 0x0F ? 0x0F : nibble; +} + +static uint8_t color_to_nibble(const Color &color) { + // Grayscale images are emitted as Color(gray, gray, gray, 0xFF). + // Handle this shape first so endpoint values don't alias COLOR_ON/OFF. + if (color.w == 0xFF && color.r == color.g && color.g == color.b) + return quantize_8bit_to_nibble(color.r); + + if (color.raw_32 == 0) + return 0x00; // black + if (color.raw_32 == 0xFFFFFFFF) + return 0x0F; // white + + // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by + // 256). Rec.601 is the standard for converting SDR images to grayscale and + // spreads saturated colours across the mid-range; Rec.709 instead crams them + // against white/black where the 16 panel levels are hard to tell apart. + auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); + return quantize_8bit_to_nibble(luma); +} + +// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). +// A pixel whose luma is below the threshold renders black, so lighter pixels +// produce progressively sparser black dots instead of vanishing to white. The +// matrix averages to 32768, matching the conventional monochrome cut, while the +// per-pixel variation reproduces intermediate gray levels. +static uint16_t dither_threshold(uint16_t x, uint16_t y) { + static const uint8_t BAYER4[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5}; + return static_cast(BAYER4[((y & 3) << 2) | (x & 3)] * 4096u + 2048u); +} + +void IT8951Display::fill(Color color) { + if (this->buffer_ == nullptr) + return; + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + uint8_t packed = color_to_nibble(color); + if (this->invert_colors_) + packed = 0x0F - packed; + uint8_t fill_byte; + if (this->grayscale_) { + fill_byte = static_cast((packed << 4) | packed); + } else { + fill_byte = (packed <= 0x07) ? 0xFF : 0x00; + } + memset(this->buffer_, fill_byte, this->buffer_length_); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { + if (this->buffer_ == nullptr) + return; + App.feed_wdt(); + if (!this->rotate_coordinates_(x, y)) + return; + this->write_pixel_native_(static_cast(x), static_cast(y), color); +} + +void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const { + if (this->grayscale_) { + uint8_t nibble = color_to_nibble(color); + if (this->invert_colors_) + nibble = static_cast(0x0F - nibble); + this->set_gray_pixel_(x, y, nibble); + } else { + // Rec.601 luma (see color_to_nibble). Weights sum to 257 so white maps to + // exactly 65535, using the full 16-bit range without overflow. + auto lum = static_cast(77u * color.r + 151u * color.g + 29u * color.b); + if (this->invert_colors_) + lum = static_cast(65535u - lum); + // Set the bit (foreground/black) when this pixel is darker than its + // threshold. With dithering the threshold varies per pixel so pale colours + // render as visible texture; otherwise it's the fixed ~50% cut (r+g+b<32768). + const uint16_t threshold = this->dithering_ ? dither_threshold(x, y) : 32768; + this->set_mono_pixel_(x, y, lum < threshold); + } +} + +void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or + // after an allocation failure; ignore those rather than dereferencing null. + if (this->buffer_ == nullptr) + return; + // A clipping rectangle would need a per-pixel test; that's rare for the bulk + // blit callers (LVGL, images), so fall back to the base per-pixel path then. + if (this->get_clipping().is_set()) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + + const size_t line_stride = static_cast(x_offset) + w + x_pad; // source line length in pixels + for (int y = 0; y < h; y++) { + App.feed_wdt(); + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x < w; x++, source_idx++) { + uint32_t color_value; + switch (bitness) { + case COLOR_BITNESS_565: { + const size_t i = source_idx * 2; + color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] + : ptr[i] | (static_cast(ptr[i + 1]) << 8); + break; + } + case COLOR_BITNESS_888: { + const size_t i = source_idx * 3; + color_value = + big_endian + ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] + : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + break; + } + default: + color_value = ptr[source_idx]; + break; + } + int nx = x_start + x; + int ny = y_start + y; + this->apply_transform_(nx, ny); + if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) + continue; + this->write_pixel_native_(static_cast(nx), static_cast(ny), + ColorUtil::to_color(color_value, order, bitness)); + } + } + + // Expand the dirty bounding box once from the transformed block corners: the + // image of an axis-aligned rectangle under swap/mirror is still axis-aligned, + // so its two opposite corners bound it. + int x0 = x_start, y0 = y_start; + int x1 = x_start + w - 1, y1 = y_start + h - 1; + this->apply_transform_(x0, y0); + this->apply_transform_(x1, y1); + const int nx_lo = std::max(0, std::min(x0, x1)); + const int ny_lo = std::max(0, std::min(y0, y1)); + const int nx_hi = std::min(this->width_ - 1, std::max(x0, x1)); + const int ny_hi = std::min(this->height_ - 1, std::max(y0, y1)); + if (nx_hi >= nx_lo && ny_hi >= ny_lo) { + this->x_low_ = clamp_at_most(this->x_low_, nx_lo); + this->x_high_ = clamp_at_least(this->x_high_, nx_hi + 1); + this->y_low_ = clamp_at_most(this->y_low_, ny_lo); + this->y_high_ = clamp_at_least(this->y_high_, ny_hi + 1); + } +} + +void IT8951Display::set_mono_pixel_(uint16_t x, uint16_t y, bool value) const { + // The monochrome framebuffer holds the exact bytes streamed to the + // controller for the 8bpp-load / 1bpp-display trick (L_ENDIAN). Pixels are + // grouped in 16s; on the wire the high byte (pixels 8..15) precedes the low + // byte (pixels 0..7), and the bit index within a byte is the pixel's offset + // (LSB = lowest x). Storing in that order lets op_xfer_rows_ copy rows + // verbatim with no packing or byte-swapping. + const uint16_t group = static_cast(x >> 4); + const uint8_t sub = static_cast(x & 0x0F); + const uint16_t byte_index = static_cast(group * 2u + (sub < 8u ? 1u : 0u)); + const uint8_t mask = static_cast(1u << (sub & 0x07)); + const uint32_t index = static_cast(y) * this->row_width_ + byte_index; + if (value) { + this->buffer_[index] |= mask; + } else { + this->buffer_[index] &= static_cast(~mask); + } +} + +void IT8951Display::set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const { + const uint32_t index = static_cast(y) * this->row_width_ + (static_cast(x) >> 1); + uint8_t buf = this->buffer_[index]; + if (x & 0x1) { + buf = (buf & 0xF0) | nibble; + } else { + buf = (buf & 0x0F) | static_cast(nibble << 4); + } + this->buffer_[index] = buf; +} + +// --- Diagnostics ------------------------------------------------------------- + +void IT8951Display::dump_config() { + LOG_DISPLAY("", "IT8951 E-Paper", this); + char force_temperature[24]; + if (this->force_temperature_set_) { + snprintf(force_temperature, sizeof(force_temperature), "%d °C", this->force_temperature_); + } else { + strncpy(force_temperature, "(controller default)", sizeof(force_temperature)); + force_temperature[sizeof(force_temperature) - 1] = '\0'; + } + ESP_LOGCONFIG(TAG, + " Model preset: %s" + "\n Dimensions: %dx%d" + "\n Buffer: %u bytes" + "\n Image buffer addr: 0x%04X%04X" + "\n VCOM: %.02fV (set selector 0x%04X)" + "\n Force temperature: %s" + "\n Display command: %s" + "\n Sleep when done: %s" + "\n Full update every: %u" + "\n Inverted colors: %s" + "\n Pixel format: %s" + "\n Reset duration: %" PRIu32 "ms", + this->name_ != nullptr ? this->name_ : "(unknown)", this->get_width_internal(), + this->get_height_internal(), static_cast(this->buffer_length_), this->img_buf_addr_h_, + this->img_buf_addr_l_, static_cast(this->vcom_) / 1000.0f, this->vcom_register_, + force_temperature, this->use_legacy_dpy_area_ ? "DPY_AREA (0x0034, legacy)" : "DPY_BUF_AREA (0x0037)", + YESNO(this->sleep_when_done_), this->full_update_every_, YESNO(this->invert_colors_), + this->grayscale_ ? "4bpp grayscale" : "1bpp monochrome", this->reset_duration_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h new file mode 100644 index 00000000000..a5ed03e8c4b --- /dev/null +++ b/esphome/components/it8951/it8951.h @@ -0,0 +1,373 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "it8951_defs.h" + +namespace esphome::it8951 { + +using namespace display; + +// --- Bounded op queue -------------------------------------------------------- +// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque +// to comply with ESPHome's STL container guidelines (std::deque allocates in +// 512-byte blocks regardless of element size). Size analysis: the deepest +// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's +// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a +// comfortable margin while keeping RAM cost low (~192 bytes per instance vs +// 512+ bytes for std::deque). +template class StaticOpQueue { + public: + bool empty() const { return this->count_ == 0; } + size_t size() const { return this->count_; } + static constexpr size_t capacity() { return N; } + + bool push_back(const T &value) { + if (this->count_ >= N) + return false; + this->data_[(this->head_ + this->count_) % N] = value; + ++this->count_; + return true; + } + + bool push_front(const T &value) { + if (this->count_ >= N) + return false; + this->head_ = (this->head_ + N - 1) % N; + this->data_[this->head_] = value; + ++this->count_; + return true; + } + + void pop_front() { + if (this->count_ == 0) + return; + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + + const T &front() const { return this->data_[this->head_]; } + T &front() { return this->data_[this->head_]; } + + void clear() { + this->head_ = 0; + this->count_ = 0; + } + + private: + T data_[N]{}; + size_t head_{0}; + size_t count_{0}; +}; + +// Op queue capacity. See StaticOpQueue comment for sizing analysis. +static constexpr size_t OP_QUEUE_SIZE = 32; + +// --- Op queue --------------------------------------------------------------- +// Each Op is a single CS-asserted SPI transaction (or a tiny bookkeeping +// step). The loop processes one Op per iteration after gating on HW_RDY, so +// the natural ESPHome loop cadence (~8-16 ms) provides inter-op pacing +// without any blocking waits. +// +// Compound Ops (READ_DEV_INFO, XFER_*, DPY_BUF_AREA, ENABLE_1BPP, ...) are +// short self-contained methods that do all their SPI work inside a single +// CS cycle (or a small handful of cycles) and complete well under 2ms, so +// they don't break the no-blocking budget. +// +// Each write-type op is a SINGLE CS-asserted transaction. The loop-level +// HW_RDY gate ensures the controller is ready before dispatching any op, so +// no blocking waits are needed within write ops. +// +// Read ops are decomposed: the command/address that triggers data preparation +// is sent as write ops (CMD, WRITE_W), then a separate read op runs only +// after the loop confirms HW_RDY is back HIGH (data ready). No blocking. +enum class OpType : uint8_t { + CMD, // single CS: CMD preamble + command word (a) + WRITE_W, // single CS: WRITE preamble + data word (a) + WRITE_REG, // single CS: WRITE preamble + addr(a) + value(b) + // (caller must enqueue CMD(TCON_REG_WR) before this) + READ_DEV_INFO, // single CS: READ preamble + dummy + read DevInfo struct + // (caller enqueues CMD(GET_DEV_INFO) first; loop HW_RDY gate + // ensures data is ready before this op runs) + READ_WORD, // single CS: READ preamble + dummy + read one 16-bit word + // into read_result_. Loop HW_RDY gate ensures data ready. + CHECK_LUT_IDLE, // checks read_result_; if non-zero, re-enqueues read sequence + SET_1BPP, // uses read_result_ to set UP1SR bit 2, enqueues writes + XFER_LISAR, // set image-buffer target address (2× reg write: 4 CS transactions) + XFER_AREA_CMD, // single CS: CMD preamble + TCON_LD_IMG_AREA + XFER_AREA_ARGS, // single CS: WRITE preamble + 5 area-parameter words + XFER_ROWS, // single CS: WRITE preamble + row pixel data (time-sliced) + XFER_AREA_END, // single CS: CMD preamble + TCON_LD_IMG_END + DPY_BUF_CMD, // single CS: CMD preamble + I80_CMD_DPY_BUF_AREA + DPY_BUF_ARGS, // single CS: WRITE preamble + 7 display-area words + GPIO_RESET_LOW, // drive RESET pin low + GPIO_RESET_HIGH, // drive RESET pin high + DELAY_MS, // park `delay_until_` for a few ms (no SPI) +}; + +struct Op { + OpType type; + uint16_t a{0}; + uint16_t b{0}; +}; + +// High-level controller phases. Each phase enqueues a sequence of Ops; when +// the queue drains, advance_phase_() runs the next phase. +// This separation keeps per-Op work tiny and predictable. +enum class Phase : uint8_t { + IDLE, + // Initialisation + INIT_RESET, // reset pulse + wake controller + packed-write enable + INIT_DEV_INFO, // GET_DEV_INFO and validate + INIT_VCOM, // write configured VCOM + INIT_TEMP, // force temperature for waveform LUT selection + INIT_DONE, // allocate framebuffer; transition to IDLE + // Update flow + UPDATE_PREPARE, // do_update_, compute dirty region, decide 4bpp/1bpp + UPDATE_TRANSFER, // one LD_IMG_AREA, time-sliced row streaming, one LD_IMG_END + UPDATE_REFRESH, // wait LUT idle, optionally enable 1bpp, send DPY_BUF_AREA + UPDATE_SLEEP, // optional deep sleep +}; + +class IT8951Display : public Display, + public spi::SPIDevice { + public: + IT8951Display(const char *name, uint16_t width, uint16_t height) : name_(name), width_(width), height_(height) { + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(height); + } + + // --- Component lifecycle --- + void setup() override; + void loop() override; + void dump_config() override; + void on_safe_shutdown() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + // --- Config setters (called from generated code) --- + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; } + void set_enable_pins(std::vector pins) { this->enable_pins_ = std::move(pins); } + void set_reset_duration(uint32_t ms) { this->reset_duration_ = ms; } + void set_full_update_every(uint8_t n) { + this->full_update_every_ = n; + // Seed the counter so the very first update trips the full-update branch in + // prepare_update_region_, giving a freshly-booted panel a clean GC16 refresh + // before any partial (fast-waveform) updates begin. + this->partial_update_count_ = n; + } + void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } + void set_sleep_when_done(bool s) { this->sleep_when_done_ = s; } + void set_vcom(uint16_t vcom_mv) { this->vcom_ = vcom_mv; } + void set_vcom_register(uint16_t selector) { this->vcom_register_ = selector; } + void set_force_temperature(int16_t celsius) { + this->force_temperature_ = celsius; + this->force_temperature_set_ = true; + } + void set_use_legacy_dpy_area(bool use) { this->use_legacy_dpy_area_ = use; } + // Pixel format: true = 4bpp grayscale framebuffer, false = packed 1bpp + // monochrome framebuffer. Chosen at config time; the framebuffer is stored + // in this native format and every update uses the matching transfer path. + void set_grayscale(bool g) { this->grayscale_ = g; } + // Monochrome only: ordered-dither pale colours (true) vs a hard 50% threshold. + void set_dithering(bool d) { this->dithering_ = d; } + void set_update_mode(uint16_t m) { this->default_update_mode_ = static_cast(m); } + void set_transform(uint8_t t) { + this->transform_ = t; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } + + // --- Display API --- + void update() override; + void update_mode(UpdateMode mode); + DisplayType get_display_type() override { return this->grayscale_ ? DISPLAY_TYPE_GRAYSCALE : DISPLAY_TYPE_BINARY; } + void fill(Color color) override; + void clear() override { this->fill(Color::WHITE); } + void draw_pixel_at(int x, int y, Color color) override; + // Bulk pixel blit (used by LVGL and image rendering). Overridden to write + // straight into the framebuffer, avoiding the base class's per-pixel + // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } + int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + + protected: + int get_height_internal() override { return this->height_; } + int get_width_internal() override { return this->width_; } + + // --- Coord transform / dirty region --- + void update_effective_transform_(); + // Map display (logical) coordinates to native framebuffer coordinates by + // applying effective_transform_ (swap/mirror). Shared by rotate_coordinates_ + // and the bulk draw_pixels_at path. + void apply_transform_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); + void reset_dirty_region_(); + + // --- Framebuffer geometry / monochrome packing --- + // Bytes per row for the configured pixel format: 4bpp grayscale packs two + // pixels per byte; monochrome packs eight bits per byte, rounded up to a + // whole 16-pixel group (matching the controller's 8bpp-load / 1bpp trick). + uint16_t compute_row_width_() const { + return this->grayscale_ ? static_cast((static_cast(this->width_) + 1) / 2) + : static_cast(((static_cast(this->width_) + 15) / 16) * 2); + } + void set_mono_pixel_(uint16_t x, uint16_t y, bool value) const; + // Write a 4bpp grayscale nibble into the framebuffer (two pixels per byte). + void set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const; + // Convert a color and write it at native framebuffer coordinates: a 4bpp + // nibble in grayscale mode, or an ordered-dithered bit in monochrome mode. + void write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const; + + // --- Op queue / loop machinery --- + void enqueue_(OpType type, uint16_t a = 0, uint16_t b = 0); + void prepend_(OpType type, uint16_t a = 0, uint16_t b = 0); + bool is_busy_() const; + void process_op_(const Op &op); + void advance_phase_(); + void set_phase_(Phase next); + void start_update_(UpdateMode mode); + + // --- SPI primitives (each is one CS-asserted burst, fully non-blocking) --- + void spi_cmd_(uint16_t cmd); + void spi_write_word_(uint16_t value); + void spi_write_reg_(uint16_t addr, uint16_t value); + void spi_write_args_(const uint16_t *args, uint16_t count); + uint16_t spi_read_word_(); // non-blocking: HW_RDY confirmed by loop gate + void spi_read_dev_info_(); // non-blocking: HW_RDY confirmed by loop gate + + // --- Compound Ops (small bounded helpers) --- + void op_xfer_lisar_(); + void op_xfer_area_args_(); + void op_xfer_area_end_(); + bool op_xfer_rows_(); // returns true when current update area fully sent + void op_dpy_buf_args_(); + void op_check_lut_idle_(); + void op_set_1bpp_(); + + // --- Phase enqueuers --- + void enqueue_init_reset_(); + void enqueue_init_dev_info_(); + void enqueue_init_vcom_(); + void enqueue_init_temp_(); + void enqueue_update_transfer_(); + void enqueue_update_refresh_(); + void enqueue_update_sleep_(); + + bool prepare_update_region_(UpdateMode &mode); + + // --- Recovery --- + void recover_(); + + // --- State --- + static constexpr uint32_t BUSY_TIMEOUT_MS = 5000; + + StaticOpQueue queue_; + Phase phase_{Phase::IDLE}; + uint32_t delay_until_{0}; + uint32_t phase_started_at_{0}; + // Requests a continuous (non-throttled) main loop while streaming image data + // so 20ms transfer slices aren't separated by the ~16ms default loop interval. + HighFrequencyLoopRequester high_freq_; + + // Pending update bookkeeping + bool update_pending_{false}; + UpdateMode pending_update_mode_{UPDATE_MODE_NONE}; + UpdateMode active_mode_{UPDATE_MODE_NONE}; + uint16_t area_x_{0}, area_y_{0}, area_w_{0}, area_h_{0}; + uint16_t transfer_row_{0}; + bool initialised_{false}; + // True once TCON_SLEEP has been sent and the controller has not been woken + // since. The next update must issue TCON_SYS_RUN before any SPI op. + bool asleep_{false}; + uint32_t partial_update_count_{0}; + uint32_t update_started_at_{0}; + + // Read result storage for decomposed read-modify-write op sequences + uint16_t read_result_{0}; + + // Device info + DevInfo dev_info_{}; + uint16_t img_buf_addr_l_{0}; + uint16_t img_buf_addr_h_{0}; + + // Configured properties + const char *name_; + uint16_t width_; + uint16_t height_; + uint16_t row_width_; + size_t buffer_length_{}; + uint8_t *buffer_{}; + uint8_t transform_{0}; + uint8_t effective_transform_{0}; + uint8_t full_update_every_{1}; + uint32_t reset_duration_{10}; + uint16_t vcom_{2300}; + uint16_t vcom_register_{I80_CMD_VCOM_WRITE}; + int16_t force_temperature_{DEFAULT_FORCE_TEMP_C}; + bool force_temperature_set_{false}; + bool use_legacy_dpy_area_{false}; + bool invert_colors_{false}; + bool sleep_when_done_{false}; + // Pixel format selector (see set_grayscale): true = 4bpp grayscale, + // false = packed 1bpp monochrome. + bool grayscale_{true}; + // Monochrome dithering (see set_dithering): true = ordered dither. + bool dithering_{true}; + UpdateMode default_update_mode_{UPDATE_MODE_NONE}; + GPIOPin *reset_pin_{nullptr}; + GPIOPin *busy_pin_{nullptr}; + // GPIOs driven high during setup to power on the panel (empty if unused). + std::vector enable_pins_; + + // Dirty region (pixel coordinates of bounding box of changes since last update) + uint16_t x_low_{0}, y_low_{0}, x_high_{0}, y_high_{0}; + + // Saved data rate so we can probe slow then run fast + uint32_t configured_data_rate_{0}; + + // Consecutive recovery attempts; used to give up rather than infinite-loop + // when the controller is unresponsive (e.g. wiring issue). + uint8_t recovery_attempts_{0}; + + // DevInfo read retry counter (controller often returns garbage on the first + // read after reset; the original driver retried up to 3 times with 100ms + // between attempts). + uint8_t dev_info_attempts_{0}; +}; + +// --- Automation action --- +template class IT8951UpdateAction : public Action { + public: + explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} + TEMPLATABLE_VALUE(UpdateMode, mode) + + protected: + void play(const Ts &...x) override { + if (!this->display_->is_ready()) + return; + if (this->mode_.has_value()) { + this->display_->update_mode(this->mode_.value(x...)); + } else { + this->display_->update(); + } + } + + IT8951Display *display_; +}; + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951_defs.h b/esphome/components/it8951/it8951_defs.h new file mode 100644 index 00000000000..9a7291eb4ad --- /dev/null +++ b/esphome/components/it8951/it8951_defs.h @@ -0,0 +1,168 @@ +#pragma once + +#include + +namespace esphome::it8951 { + +struct DevInfo { + uint16_t panel_width{0}; + uint16_t panel_height{0}; + uint16_t img_buf_addr_l{0}; + uint16_t img_buf_addr_h{0}; + uint16_t fw_version[8]{}; + uint16_t lut_version[8]{}; +}; + +// --- IT8951 SPI packet preambles --- +static constexpr uint16_t PACKET_TYPE_CMD = 0x6000; +static constexpr uint16_t PACKET_TYPE_WRITE = 0x0000; +static constexpr uint16_t PACKET_TYPE_READ = 0x1000; + +// --- Built-in I80 commands --- +static constexpr uint16_t TCON_SYS_RUN = 0x0001; +static constexpr uint16_t TCON_STANDBY = 0x0002; +static constexpr uint16_t TCON_SLEEP = 0x0003; +static constexpr uint16_t TCON_REG_RD = 0x0010; +static constexpr uint16_t TCON_REG_WR = 0x0011; + +static constexpr uint16_t TCON_LD_IMG = 0x0020; +static constexpr uint16_t TCON_LD_IMG_AREA = 0x0021; +static constexpr uint16_t TCON_LD_IMG_END = 0x0022; + +// --- I80 user-defined commands --- +static constexpr uint16_t I80_CMD_DPY_AREA = 0x0034; +static constexpr uint16_t I80_CMD_GET_DEV_INFO = 0x0302; +static constexpr uint16_t I80_CMD_DPY_BUF_AREA = 0x0037; +static constexpr uint16_t I80_CMD_VCOM = 0x0039; +static constexpr uint16_t I80_CMD_VCOM_READ = 0x0000; +// VCOM write selectors. Different IT8951-driven panels accept different +// selector values for the VCOM SET sub-command. Most panels (m5stack-m5paper, +// generic dev kits) accept 0x0001. Some panels — notably the Seeed +// reTerminal E1003 — only respond to selector 0x0002 and silently ignore +// 0x0001, leaving VCOM at its default and making grayscale waveforms +// (GC16/GL16) ineffective even though INIT still works. +static constexpr uint16_t I80_CMD_VCOM_WRITE = 0x0001; +static constexpr uint16_t I80_CMD_VCOM_WRITE_ALT = 0x0002; + +// Force temperature command. The IT8951 selects waveform LUTs based on +// panel temperature; if it is left at the controller default, panels with +// auto-temperature disabled (notably the Seeed reTerminal E1003) will +// run waveforms against a mismatched LUT, leaving pixels visually +// unchanged even though the LUT engine completes a full cycle. The +// selector word selects the operation (0x0001 = write); the value word +// is the temperature in degrees Celsius. +static constexpr uint16_t I80_CMD_FORCE_TEMP = 0x0040; +static constexpr uint16_t I80_CMD_FORCE_TEMP_WRITE = 0x0001; +static constexpr int16_t DEFAULT_FORCE_TEMP_C = 25; + +// --- Pixel mode (bits per pixel encoding) --- +static constexpr uint8_t PIXEL_2BPP = 0; +static constexpr uint8_t PIXEL_3BPP = 1; +static constexpr uint8_t PIXEL_4BPP = 2; +static constexpr uint8_t PIXEL_8BPP = 3; + +// --- Endian flags for LD_IMG_AREA --- +static constexpr uint8_t LDIMG_L_ENDIAN = 0; +static constexpr uint8_t LDIMG_B_ENDIAN = 1; + +// --- SPI probe frequency used for initial controller handshake --- +static constexpr uint32_t SPI_PROBE_FREQUENCY = 1'000'000; + +// --- Refresh modes --- +/* + INIT The initialization (INIT) mode is + used to completely erase the display and leave it in the white state. It is + useful for situations where the display information in memory is not a faithful + representation of the optical state of the display, for example, after the + device receives power after it has been fully powered down. This waveform + switches the display several times and leaves it in the white state. + + DU + The direct update (DU) is a very fast, non-flashy update. This mode supports + transitions from any graytone to black or white only. It cannot be used to + update to any graytone other than black or white. The fast update time for this + mode makes it useful for response to touch sensor or pen input or menu selection + indictors. + + GC16 + The grayscale clearing (GC16) mode is used to update the full display and + provide a high image quality. When GC16 is used with Full Display Update the + entire display will update as the new image is written. If a Partial Update + command is used the only pixels with changing graytone values will update. The + GC16 mode has 16 unique gray levels. + + GL16 + The GL16 waveform is primarily used to update sparse content on a white + background, such as a page of anti-aliased text, with reduced flash. The + GL16 waveform has 16 unique gray levels. + + GLR16 + The GLR16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. The GLR16 mode supports 16 graytones. If only the even pixel states + are used (0, 2, 4, … 30), the mode will behave exactly as a traditional GL16 + waveform mode. If a separately-supplied image preprocessing algorithm is used, + the transitions invoked by the pixel states 29 and 31 are used to improve + display quality. For the AF waveform, it is assured that the GLR16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + GLD16 + The GLD16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. It is recommended to be used only with the full display update. The + GLD16 mode supports 16 graytones. If only the even pixel states are used (0, 2, + 4, … 30), the mode will behave exactly as a traditional GL16 waveform mode. If a + separately-supplied image preprocessing algorithm is used, the transitions + invoked by the pixel states 29 and 31 are used to refresh the background with a + lighter flash compared to GC16 mode following a predetermined pixel map as + encoded in the waveform file, and reduce image artifacts even more compared to + the GLR16 mode. For the AF waveform, it is assured that the GLD16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + DU4 + The DU4 is a fast update time (similar to DU), non-flashy waveform. This mode + supports transitions from any gray tone to gray tones 1,6,11,16 represented by + pixel states [0 10 20 30]. The combination of fast update time and four gray + tones make it useful for anti-aliased text in menus. There is a moderate + increase in ghosting compared with GC16. + + A2 + The A2 mode is a fast, non-flash update mode designed for fast paging turning or + simple black/white animation. This mode supports transitions from and to black + or white only. It cannot be used to update to any graytone other than black or + white. The recommended update sequence to transition into repeated A2 updates is + shown in Figure 1. The use of a white image in the transition from 4-bit to + 1-bit images will reduce ghosting and improve image quality for A2 updates. + */ +enum UpdateMode : uint16_t { + UPDATE_MODE_INIT = 0, + UPDATE_MODE_DU = 1, + UPDATE_MODE_GC16 = 2, + UPDATE_MODE_GL16 = 3, + UPDATE_MODE_GLR16 = 4, + UPDATE_MODE_GLD16 = 5, + UPDATE_MODE_DU4 = 6, + UPDATE_MODE_A2 = 7, + UPDATE_MODE_NONE = 8, +}; + +// --- Registers --- +static constexpr uint16_t DISPLAY_REG_BASE = 0x1000; +static constexpr uint16_t UP1SR = DISPLAY_REG_BASE + 0x138; +static constexpr uint16_t LUTAFSR = DISPLAY_REG_BASE + 0x224; +static constexpr uint16_t BGVR = DISPLAY_REG_BASE + 0x250; + +static constexpr uint16_t I80CPCR = 0x0004; + +static constexpr uint16_t MCSR_BASE_ADDR = 0x0200; +static constexpr uint16_t LISAR = MCSR_BASE_ADDR + 0x0008; + +// Display orientation flags +static constexpr uint8_t TRANSFORM_NONE = 0; +static constexpr uint8_t TRANSFORM_MIRROR_X = 1; +static constexpr uint8_t TRANSFORM_MIRROR_Y = 2; +static constexpr uint8_t TRANSFORM_SWAP_XY = 4; + +} // namespace esphome::it8951 diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..c362f7f28c4 --- /dev/null +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -0,0 +1,109 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +display: + # Generic IT8951 with explicit dimensions + - platform: it8951 + spi_id: spi_bus + model: it8951 + dimensions: + width: 1872 + height: 1404 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + enable_pin: + - GPIO17 + - GPIO18 + vcom: 1500 + update_interval: 60s + # Exercise an alias for the update_mode config option. + update_mode: fast + lambda: |- + it.circle(64, 64, 50, Color::BLACK); + + # m5stack-m5paper (960x540) — model supplies pin defaults + - platform: it8951 + id: m5epd_display + spi_id: spi_bus + model: m5stack-m5paper + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + full_update_every: 30 + invert_colors: false + sleep_when_done: true + grayscale: true + update_mode: GC16 + rotation: 270 + transform: + mirror_x: false + mirror_y: false + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + + # seeed-reterminal-e1003 (1872x1404) + - platform: it8951 + spi_id: spi_bus + model: seeed-reterminal-e1003 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + vcom: 1400 + sleep_when_done: false + lambda: |- + it.filled_rectangle(0, 0, 128, 128, Color::BLACK); + + # seeed-ee03 (1872x1404), monochrome fast path + - platform: it8951 + spi_id: spi_bus + model: seeed-ee03 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + grayscale: false + dithering: false + update_mode: DU + lambda: |- + it.circle(128, 128, 64, Color::BLACK); + +# Exercise the it8951.update automation: alias modes, a direct enum-name mode, +# and the bare (default-mode) form. +interval: + - interval: 30s + then: + - it8951.update: + id: m5epd_display + mode: fast + - it8951.update: + id: m5epd_display + mode: full + - it8951.update: + id: m5epd_display + mode: A2 + - it8951.update: m5epd_display diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index 304634edcaa..de912ddcbca 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -18,6 +18,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // USE_ESP8266 || USE_ESP32 }; // Expose protected members for testing. From 45c712b17be94d71b309678f97549a7dfe224278 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:41:49 -0700 Subject: [PATCH 195/343] Bump bundled esphome-device-builder to 1.0.21 (#17257) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5626d18fcc1..10850761371 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.20 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 RUN \ platformio settings set enable_telemetry No \ From 6210dfb4d099651ea1731a19bd569eef0970109f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:01 -0400 Subject: [PATCH 196/343] [core] Use single-precision float math to avoid double promotion (#17252) --- esphome/core/helpers.cpp | 10 +++++----- esphome/core/scheduler.cpp | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 112dde7c450..a7b63643a40 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -669,11 +669,11 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, if (delta == 0) { hue = 0; } else if (max_color_value == red) { - hue = int(fmod(((60 * ((green - blue) / delta)) + 360), 360)); + hue = int(fmodf((60.0f * ((green - blue) / delta)) + 360.0f, 360.0f)); } else if (max_color_value == green) { - hue = int(fmod(((60 * ((blue - red) / delta)) + 120), 360)); + hue = int(fmodf((60.0f * ((blue - red) / delta)) + 120.0f, 360.0f)); } else if (max_color_value == blue) { - hue = int(fmod(((60 * ((red - green) / delta)) + 240), 360)); + hue = int(fmodf((60.0f * ((red - green) / delta)) + 240.0f, 360.0f)); } if (max_color_value == 0) { @@ -686,8 +686,8 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, } void hsv_to_rgb(int hue, float saturation, float value, float &red, float &green, float &blue) { float chroma = value * saturation; - float hue_prime = fmod(hue / 60.0, 6); - float intermediate = chroma * (1 - fabs(fmod(hue_prime, 2) - 1)); + float hue_prime = fmodf(hue / 60.0f, 6.0f); + float intermediate = chroma * (1.0f - fabsf(fmodf(hue_prime, 2.0f) - 1.0f)); float delta = value - chroma; if (0 <= hue_prime && hue_prime < 1) { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 9c5557bdfce..8449cba5e81 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -356,7 +356,7 @@ void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, } #endif - if (backoff_increase_factor < 0.0001) { + if (backoff_increase_factor < 0.0001f) { ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); backoff_increase_factor = 1; From 40820287f17e51b74fbb091d6829f9df6c7ae7c7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:13 -0400 Subject: [PATCH 197/343] [multiple] Single-precision float math, avoid double promotion (batch 1/4) (#17253) --- esphome/components/daikin_brc/daikin_brc.cpp | 2 +- .../components/dfrobot_sen0395/commands.cpp | 54 +++++++++---------- esphome/components/display/display.cpp | 2 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 2 +- esphome/components/light/transformers.h | 2 +- .../mcp4461/output/mcp4461_output.cpp | 4 +- esphome/components/opentherm/opentherm.cpp | 2 +- esphome/components/qmp6988/qmp6988.cpp | 2 +- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- .../speaker_source_media_player.cpp | 2 +- esphome/components/veml7700/veml7700.cpp | 2 +- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 2 +- 12 files changed, 40 insertions(+), 38 deletions(-) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5fe3d30a850..1b085013f1d 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -151,7 +151,7 @@ uint8_t DaikinBrcClimate::temperature_() { // Temperature in remote is in F if (this->fahrenheit_) { temperature = (uint8_t) roundf( - clamp(((this->target_temperature * 1.8) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); + clamp(((this->target_temperature * 1.8f) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); } else { temperature = ((uint8_t) roundf(this->target_temperature) - 9) << 1; } diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 29ee166f51f..570bfef9439 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -121,51 +121,51 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->cmd_ = "detRangeCfg -1 0 0"; } else if (min2 < 0 || max2 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15f, max1 / 0.15f); this->cmd_ = buf; } else if (min3 < 0 || max3 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f); this->cmd_ = buf; } else if (min4 < 0 || max4 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15, min3 / 0.15, max3 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f, min3 / 0.15f, max3 / 0.15f); this->cmd_ = buf; } else { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; - this->min4_ = min4 = round(min4 / 0.15) * 0.15; - this->max4_ = max4 = round(max4 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; + this->min4_ = min4 = roundf(min4 / 0.15f) * 0.15f; + this->max4_ = max4 = roundf(max4 / 0.15f) * 0.15f; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, - min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15, max4 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, + min2 / 0.15f, max2 / 0.15f, min3 / 0.15f, max3 / 0.15f, min4 / 0.15f, max4 / 0.15f); this->cmd_ = buf; } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b24c099bce3..b30f444d6d8 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -228,7 +228,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int e2max, e2min; progress = std::max(0, std::min(progress, 100)); // 0..100 int draw_progress = progress > 50 ? (100 - progress) : progress; - float tan_a = (progress == 50) ? 65535 : tan(float(draw_progress) * M_PI / 100); // slope + float tan_a = (progress == 50) ? 65535 : tanf(float(draw_progress) * std::numbers::pi_v / 100); // slope do { // outer dots diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 0b3a746c34f..270bb2709dd 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -55,7 +55,7 @@ void HrxlMaxsonarWrComponent::check_buffer_() { millimeters = millimeters * 10; } - float meters = float(millimeters) / 1000.0; + float meters = float(millimeters) / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); this->publish_state(meters); } else { diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 61fe098ad74..34e192a0346 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -47,7 +47,7 @@ class LightTransitionTransformer : public LightTransformer { LightColorValues &start = this->changing_color_mode_ && p > 0.5f ? this->intermediate_values_ : this->start_values_; LightColorValues &end = this->changing_color_mode_ && p < 0.5f ? this->intermediate_values_ : this->end_values_; if (this->changing_color_mode_) - p = p < 0.5f ? p * 2 : (p - 0.5) * 2; + p = p < 0.5f ? p * 2 : (p - 0.5f) * 2; float v = LightTransformer::smoothed_progress(p); return LightColorValues::lerp(start, end, v); diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 6912ad5f36f..3892372cabd 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -29,7 +29,9 @@ void Mcp4461Wiper::write_state(float state) { } } -float Mcp4461Wiper::read_state() { return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0); } +float Mcp4461Wiper::read_state() { + return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0f); +} float Mcp4461Wiper::update_state() { this->state_ = this->read_state(); diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 1ee4c9191bc..5cf7c19880b 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -541,7 +541,7 @@ void OpenTherm::debug_error(OpenThermError &error) const { error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0; } +float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index bb47e7b0f54..547991f75e0 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -276,7 +276,7 @@ void QMP6988Component::write_oversampling_temperature_(QMP6988Oversampling overs void QMP6988Component::calculate_altitude_(float pressure, float temp) { float altitude; - altitude = (pow((101325 / pressure), 1 / 5.257) - 1) * (temp + 273.15) / 0.0065; + altitude = (powf((101325 / pressure), 1 / 5.257f) - 1) * (temp + 273.15f) / 0.0065f; this->qmp6988_data_.altitude = altitude; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index b0f43f0ffca..b69e4175912 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -207,7 +207,7 @@ bool ShellyDimmer::upgrade_firmware_() { uint16_t ShellyDimmer::convert_brightness_(float brightness) { // Special case for zero as only zero means turn off completely. - if (brightness == 0.0) { + if (brightness == 0.0f) { return 0; } diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 87fd4fe9ed5..a33a1a16509 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 80e6f872abb..594c9da1704 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -380,7 +380,7 @@ void VEML7700Component::apply_lux_compensation_(Readings &data) { // if this light level is exceeded" auto compensate = [&local_data](float &lux) { auto calculate_high_lux_compensation = [](float lux_veml) -> float { - return (((6.0135e-13 * lux_veml - 9.3924e-9) * lux_veml + 8.1488e-5) * lux_veml + 1.0023) * lux_veml; + return (((6.0135e-13f * lux_veml - 9.3924e-9f) * lux_veml + 8.1488e-5f) * lux_veml + 1.0023f) * lux_veml; }; if (lux > 1000.0f || local_data.actual_gain == Gain::X_1_8 || local_data.actual_gain == Gain::X_1_4) { diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index a4303b055ab..c2b3ec14376 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From b7803cf9b5a29613fef70f253281ffc168330452 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:20 -0400 Subject: [PATCH 198/343] [multiple] Single-precision float math, avoid double promotion (batch 2/4) (#17254) --- esphome/components/a01nyub/a01nyub.cpp | 2 +- .../binary_sensor_map/binary_sensor_map.cpp | 2 +- esphome/components/bl0942/bl0942.cpp | 4 ++-- .../components/dallas_temp/dallas_temp.cpp | 2 +- esphome/components/ds2484/ds2484.h | 2 +- .../grove_tb6612fng/grove_tb6612fng.cpp | 4 ++-- .../components/honeywellabp/honeywellabp.cpp | 8 ++++--- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- esphome/components/ltr390/ltr390.cpp | 2 +- esphome/components/mcp4725/mcp4725.cpp | 2 +- esphome/components/mics_4514/mics_4514.cpp | 22 +++++++++---------- .../opentherm/output/opentherm_output.cpp | 5 +++-- .../runtime_stats/runtime_stats.cpp | 2 +- .../components/sound_level/sound_level.cpp | 2 +- esphome/components/sx127x/sx127x.cpp | 6 ++--- .../thermopro_ble/thermopro_ble.cpp | 2 +- esphome/components/x9c/x9c.cpp | 2 +- 17 files changed, 37 insertions(+), 34 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 344456854bf..6111af2b7e5 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() { if (this->buffer_[3] == checksum) { float distance = (this->buffer_[1] << 8) + this->buffer_[2]; if (distance > 280) { - float meters = distance / 1000.0; + float meters = distance / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters); this->publish_state(meters); } else { diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 316d44ba59e..3185f156976 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -112,7 +112,7 @@ float BinarySensorMap::bayesian_predicate_(bool sensor_state, float prior, float prob_state_source_false = 1 - prob_given_false; } - return prob_state_source_true / (prior * prob_state_source_true + (1.0 - prior) * prob_state_source_false); + return prob_state_source_true / (prior * prob_state_source_true + (1.0f - prior) * prob_state_source_false); } void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float value) { diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 1c57616c826..e952df21bed 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -124,14 +124,14 @@ void BL0942::setup() { // If either current or voltage references are set explicitly by the user, // calculate the power reference from it unless that is also explicitly set. if ((this->current_reference_set_ || this->voltage_reference_set_) && !this->power_reference_set_) { - this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0 / 305978.0) / 73989.0; + this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0f / 305978.0f) / 73989.0f; this->power_reference_set_ = true; } // Similarly for energy reference, if the power reference was set by the user // either implicitly or explicitly. if (this->power_reference_set_ && !this->energy_reference_set_) { - this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4; + this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4f; this->energy_reference_set_ = true; } diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 35488eab03f..ab4a8c458fe 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -138,7 +138,7 @@ float DallasTemperatureSensor::get_temp_c_() { if (this->scratch_pad_[7] == 0) { return NAN; } - return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; + return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25f; } switch (this->resolution_) { case 9: diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index b3337539ce4..819b9456c1a 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -12,7 +12,7 @@ class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevic public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::BUS - 1.0; } + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } bool reset_device(); int reset_int() override; diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index eaa1440c4da..2c68eef623c 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -122,7 +122,7 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps, rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw buffer_[2] = steps; @@ -153,7 +153,7 @@ void GroveMotorDriveTB6612FNG::stepper_keep_run(StepperModeTypeT mode, uint16_t uint16_t ms_per_step = 0; rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index 8bfc5e4f4f8..dd86b95c787 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -55,7 +55,9 @@ float HONEYWELLABPSensor::countstopressure_(const int counts, const float min_pr // Converts a digital temperature measurement in counts to temperature in C // This will be invalid if sensore daoes not have temperature measurement capability -float HONEYWELLABPSensor::countstotemperatures_(const int counts) { return (((float) counts / 2047.0) * 200.0) - 50.0; } +float HONEYWELLABPSensor::countstotemperatures_(const int counts) { + return (((float) counts / 2047.0f) * 200.0f) - 50.0f; +} // Pressure value from the most recent reading in units float HONEYWELLABPSensor::read_pressure_() { @@ -69,9 +71,9 @@ void HONEYWELLABPSensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP Sensor"); if (readsensor_() == 0) { if (this->pressure_sensor_ != nullptr) - this->pressure_sensor_->publish_state(read_pressure_() * 1.0); + this->pressure_sensor_->publish_state(read_pressure_() * 1.0f); if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(read_temperature_() * 1.0); + this->temperature_sensor_->publish_state(read_temperature_() * 1.0f); } } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index c6ff42495f6..5e271e671e5 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -139,7 +139,7 @@ void I2SAudioSpeakerBase::set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { - if (volume > 0.0) { + if (volume > 0.0f) { this->audio_dac_->set_mute_off(); } this->audio_dac_->set_volume(volume); diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index 62a0d2290ae..dd78b20f2c2 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -75,7 +75,7 @@ void LTR390Component::read_als_() { uint32_t als = *val; if (this->light_sensor_ != nullptr) { - float lux = ((0.6 * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; + float lux = ((0.6f * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; this->light_sensor_->publish_state(lux); } diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index a32527c7256..21aff90fae0 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,7 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) round(state * (pow(2, MCP4725_RES) - 1)); + const uint16_t value = (uint16_t) roundf(state * (powf(2, MCP4725_RES) - 1)); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index d99d4fd7723..14a73bc15f7 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -71,10 +71,10 @@ void MICS4514Component::update() { float co = 0.0f; if (red_f > 3.4f) { co = 0.0; - } else if (red_f < 0.01) { + } else if (red_f < 0.01f) { co = 1000.0; } else { - co = 4.2 / pow(red_f, 1.2); + co = 4.2f / powf(red_f, 1.2f); } this->carbon_monoxide_sensor_->publish_state(co); } @@ -84,47 +84,47 @@ void MICS4514Component::update() { if (ox_f < 0.3f) { nitrogendioxide = 0.0; } else { - nitrogendioxide = 0.164 * pow(ox_f, 0.975); + nitrogendioxide = 0.164f * powf(ox_f, 0.975f); } this->nitrogen_dioxide_sensor_->publish_state(nitrogendioxide); } if (this->methane_sensor_ != nullptr) { float methane = 0.0f; - if (red_f > 0.9f || red_f < 0.5) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.5f) { // outside the range->unlikely methane = 0.0; } else { - methane = 630 / pow(red_f, 4.4); + methane = 630 / powf(red_f, 4.4f); } this->methane_sensor_->publish_state(methane); } if (this->ethanol_sensor_ != nullptr) { float ethanol = 0.0f; - if (red_f > 1.0f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 1.0f || red_f < 0.02f) { // outside the range->unlikely ethanol = 0.0; } else { - ethanol = 1.52 / pow(red_f, 1.55); + ethanol = 1.52f / powf(red_f, 1.55f); } this->ethanol_sensor_->publish_state(ethanol); } if (this->hydrogen_sensor_ != nullptr) { float hydrogen = 0.0f; - if (red_f > 0.9f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.02f) { // outside the range->unlikely hydrogen = 0.0; } else { - hydrogen = 0.85 / pow(red_f, 1.75); + hydrogen = 0.85f / powf(red_f, 1.75f); } this->hydrogen_sensor_->publish_state(hydrogen); } if (this->ammonia_sensor_ != nullptr) { float ammonia = 0.0f; - if (red_f > 0.98f || red_f < 0.2532) { // outside the ammonia range->unlikely + if (red_f > 0.98f || red_f < 0.2532f) { // outside the ammonia range->unlikely ammonia = 0.0; } else { - ammonia = 0.9 / pow(red_f, 4.6); + ammonia = 0.9f / powf(red_f, 4.6f); } this->ammonia_sensor_->publish_state(ammonia); } diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 4092358d758..9b87cd8d12d 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -12,8 +12,9 @@ void opentherm::OpenthermOutput::write_state(float state) { #else bool zero_means_zero = false; #endif - this->state = - state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); + this->state = state < 0.003f && zero_means_zero + ? 0.0f + : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index d733394b78d..12e4d14ba26 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -95,7 +95,7 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0f); } } diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index a93e3963674..99ab7932d68 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -121,7 +121,7 @@ void SoundLevelComponent::loop() { if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values if (this->peak_sensor_ != nullptr) { - const float peak_db = 10.0f * log10(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); + const float peak_db = 10.0f * log10f(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); this->peak_sensor_->publish_state(peak_db); this->squared_peak_ = 0; // reset accumulator diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 0596e91ccc0..040a3064bc1 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -201,8 +201,8 @@ void SX127x::configure_fsk_ook_() { this->write_register_(REG_OOK_AVG, OOK_AVG_RESERVED | OOK_THRESH_DEC_1_8); // set rx floor - this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0)); - this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0))); + this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0f)); + this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0f))); } void SX127x::configure_lora_() { @@ -225,7 +225,7 @@ void SX127x::configure_lora_() { } // optimize detection - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; if (duration > 16) { this->write_register_(REG_MODEM_CONFIG3, MODEM_AGC_AUTO_ON | LOW_DATA_RATE_OPTIMIZE_ON); } else { diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 1ccf59a2f66..2a950d36645 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -196,7 +196,7 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz result.humidity = static_cast(data[3]); // battery level, 2 bits (0-2) - result.battery_level = static_cast(data[4] & 0x3) * 50.0; + result.battery_level = static_cast(data[4] & 0x3) * 50.0f; return result; } diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index 52ce328b3c6..b0ad79e51cd 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -44,7 +44,7 @@ void X9cOutput::setup() { this->ud_pin_->get_pin(); this->ud_pin_->setup(); - if (this->initial_value_ <= 0.50) { + if (this->initial_value_ <= 0.50f) { this->trim_value(-101); // Set min value (beyond 0) this->trim_value(lroundf(this->initial_value_ * 100)); } else { From 556def78aaaec597f7bd737de77c7c89e46e08a3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:30 -0400 Subject: [PATCH 199/343] [multiple] Single-precision float math, avoid double promotion (batch 3/4) (#17255) --- esphome/components/anova/anova_base.cpp | 4 ++-- esphome/components/bl0906/bl0906.cpp | 2 +- esphome/components/combination/combination.cpp | 2 +- esphome/components/demo/demo_sensor.h | 2 +- esphome/components/demo/demo_text_sensor.h | 4 ++-- esphome/components/es7243e/es7243e.cpp | 8 ++++---- esphome/components/haier/hon_climate.cpp | 2 +- esphome/components/ina219/ina219.cpp | 2 +- esphome/components/light/light_color_values.h | 4 ++-- esphome/components/ltr501/ltr501.cpp | 12 ++++++------ esphome/components/max17043/max17043.cpp | 2 +- esphome/components/msa3xx/msa3xx.cpp | 2 +- esphome/components/openthread/openthread.h | 2 +- esphome/components/spa06_base/spa06_base.cpp | 2 +- esphome/components/tcs34725/tcs34725.cpp | 4 ++-- esphome/components/toshiba/toshiba.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 8 ++++---- .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 2 +- 18 files changed, 33 insertions(+), 33 deletions(-) diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index 84dd4393eb2..806a441dcda 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -6,9 +6,9 @@ namespace esphome::anova { -float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); } +float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); } -float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; } +float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; } AnovaPacket *AnovaCodec::clean_packet_() { this->packet_.length = strlen((char *) this->packet_.data); diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index d3877570512..9a27cffd043 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -205,7 +205,7 @@ void BL0906::read_data_(const uint8_t address, const float reference, sensor::Se // Chip temperature if (reference == BL0906_TREF) { value = (float) to_int32_t(data_s24); - value = (value - 64) * 12.5 / 59 - 40; + value = (value - 64) * 12.5f / 59 - 40; } sensor->publish_state(value); } diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index ddf1a105e0e..8ef0976e3b7 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -204,7 +204,7 @@ void MedianCombinationComponent::handle_new_value(float value) { median = sensor_states[sensor_states_size / 2]; } else { // Even number of measurements, use the average of the two middle measurements - median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0; + median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0f; } } diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 6153c810e1c..ff2163776cf 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -15,7 +15,7 @@ class DemoSensor final : public sensor::Sensor, public PollingComponent { float base = std::isnan(this->state) ? 0.0f : this->state; this->publish_state(base + val * 10); } else { - if (val < 0.1) { + if (val < 0.1f) { this->publish_state(NAN); } else { this->publish_state(val * 100); diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index fa728903d9e..8eaa6c6b462 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -10,9 +10,9 @@ class DemoTextSensor final : public text_sensor::TextSensor, public PollingCompo public: void update() override { float val = random_float(); - if (val < 0.33) { + if (val < 0.33f) { this->publish_state("foo"); - } else if (val < 0.66) { + } else if (val < 0.66f) { this->publish_state("bar"); } else { this->publish_state("foobar"); diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index b4d9fba4c52..fc3cba7ae42 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -105,14 +105,14 @@ bool ES7243E::configure_mic_gain_() { uint8_t ES7243E::es7243e_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) mic_gain / 3; } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ad9b00ce4f..f68404afd95 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -607,7 +607,7 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 85da1965848..833d1989c8a 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -119,7 +119,7 @@ void INA219Component::setup() { } this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.04096f / (0.000001 * lsb * this->shunt_resistance_ohm_)); + auto calibration = uint32_t(0.04096f / (0.000001f * lsb * this->shunt_resistance_ohm_)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA219_REGISTER_CALIBRATION, calibration)) { this->mark_failed(); diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 5cafa9fe827..e431a06df62 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -315,14 +315,14 @@ class LightColorValues { if (this->color_temperature_ <= 0) { return this->color_temperature_; } - return 1000000.0 / this->color_temperature_; + return 1000000.0f / this->color_temperature_; } /// Set the color temperature property of these light color values in kelvin. void set_color_temperature_kelvin(float color_temperature) { if (color_temperature <= 0) { return; } - this->color_temperature_ = 1000000.0 / color_temperature; + this->color_temperature_ = 1000000.0f / color_temperature; } /// Get the cold white property of these light color values. In range 0.0 to 1.0. diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 9cba06e483b..afdc271167d 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -500,12 +500,12 @@ void LTRAlsPs501Component::apply_lux_calculation_(AlsReadings &data) { // method from // https://github.com/fards/Ainol_fire_kernel/blob/83832cf8a3082fd8e963230f4b1984479d1f1a84/customer/drivers/lightsensor/ltr501als.c#L295 - if (ratio < 0.45) { - lux = 1.7743 * ch0 + 1.1059 * ch1; - } else if (ratio < 0.64) { - lux = 3.7725 * ch0 - 1.3363 * ch1; - } else if (ratio < 0.85) { - lux = 1.6903 * ch0 - 0.1693 * ch1; + if (ratio < 0.45f) { + lux = 1.7743f * ch0 + 1.1059f * ch1; + } else if (ratio < 0.64f) { + lux = 3.7725f * ch0 - 1.3363f * ch1; + } else if (ratio < 0.85f) { + lux = 1.6903f * ch0 - 0.1693f * ch1; } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index b59bac7ebf5..8776bb5558b 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -23,7 +23,7 @@ void MAX17043Component::update() { if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) { this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL")); } else { - float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0; + float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f; this->voltage_sensor_->publish_state(voltage); this->status_clear_warning(); } diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index f23fcfc8eac..ecde0cb1172 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "msa3xx"; const uint8_t MSA_3XX_PART_ID = 0x13; const float GRAVITY_EARTH = 9.80665f; -const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9); // LSB to 1 LSB = 3.9mg = 0.0039g +const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9f); // LSB to 1 LSB = 3.9mg = 0.0039g const float G_OFFSET_MIN = -4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe const float G_OFFSET_MAX = 4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a96941325cf..eb48d8a74ad 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -85,7 +85,7 @@ class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise - float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } + float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, const otSrpClientService *removed_services, void *context); diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index b0490628cbc..d3de5168e48 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -224,7 +224,7 @@ bool SPA06Component::soft_reset_() { } // Temperature conversion formula. See datasheet pg. 14 -float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5 + this->c1_ * t_raw_sc; } +float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5f + this->c1_ * t_raw_sc; } // Pressure conversion formula. See datasheet pg. 14 float SPA06Component::convert_pressure_(const float &p_raw_sc, const float &t_raw_sc) { float p2_raw_sc = p_raw_sc * p_raw_sc; diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 40c65e9f84b..b5853927900 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -256,7 +256,7 @@ void TCS34725Component::update() { // increase only if not already maximum // do not use max gain, as ist will not get better if (this->gain_reg_ < 3) { - if (((float) raw_c / 655.35 < 20.f) && (this->integration_time_ > 600.f)) { + if (((float) raw_c / 655.35f < 20.f) && (this->integration_time_ > 600.f)) { gain_reg_val_new = this->gain_reg_ + 1; // update integration time to new situation integration_time_ideal = integration_time_ideal / 4; @@ -265,7 +265,7 @@ void TCS34725Component::update() { // decrease gain, if very high clear values and integration times alreadey low if (this->gain_reg_ > 0) { - if (70 < ((float) raw_c / 655.35) && (this->integration_time_ < 200)) { + if (70 < ((float) raw_c / 655.35f) && (this->integration_time_ < 200)) { gain_reg_val_new = this->gain_reg_ - 1; // update integration time to new situation integration_time_ideal = integration_time_ideal * 4; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 1b37c6897da..19950bbd159 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -593,7 +593,7 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { message[3] = ~message[2]; // Byte 4u: Temp if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - temperature = (temperature * 1.8) + 32; + temperature = (temperature * 1.8f) + 32; temp_adjd = temperature - TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN; } diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index bd2dc2836ed..d595b37a83c 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -70,19 +70,19 @@ float UFireISEComponent::measure_ph_(float temperature) { if (mv == -1) return -1; - ph = fabs(7.0 - (mv / PROBE_MV_TO_PH)); + ph = fabsf(7.0f - (mv / PROBE_MV_TO_PH)); // Determine the temperature correction float distance_from_7 = std::abs(7 - roundf(ph)); float distance_from_25 = std::floor(std::abs(25 - roundf(temperature)) / 10); float temp_multiplier = (distance_from_25 * distance_from_7) * PROBE_TMP_CORRECTION; - if ((ph >= 8.0) && (temperature >= 35)) + if ((ph >= 8.0f) && (temperature >= 35)) temp_multiplier *= -1; - if ((ph <= 6.0) && (temperature <= 15)) + if ((ph <= 6.0f) && (temperature <= 15)) temp_multiplier *= -1; ph += temp_multiplier; - if ((ph <= 0.0) || (ph > 14.0)) + if ((ph <= 0.0f) || (ph > 14.0f)) ph = -1; if (std::isinf(ph)) ph = -1; diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index a0a92601563..7aa4809e24a 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From 95449068e72b10be110a0d4fc070e4b2ae87c1f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:18:41 -0400 Subject: [PATCH 200/343] [multiple] Single-precision float math, avoid double promotion (batch 4/4) (#17256) --- esphome/components/am43/cover/am43_cover.cpp | 10 +++++----- esphome/components/bl0940/bl0940.cpp | 2 +- .../components/current_based/current_based_cover.cpp | 2 +- esphome/components/demo/demo_switch.h | 2 +- esphome/components/es7210/es7210.cpp | 8 ++++---- esphome/components/graph/graph.cpp | 4 ++-- esphome/components/haier/smartair2_climate.cpp | 2 +- esphome/components/ina226/ina226.cpp | 2 +- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 12 ++++++------ esphome/components/mcp3204/mcp3204.cpp | 2 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 6 +++--- esphome/components/mqtt/mqtt_climate.cpp | 4 ++-- esphome/components/nextion/nextion_commands.cpp | 2 +- esphome/components/pid/pid_autotuner.cpp | 2 +- esphome/components/servo/servo.cpp | 2 +- .../speaker/media_player/speaker_media_player.cpp | 2 +- esphome/components/veml3235/veml3235.cpp | 2 +- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp | 2 +- 18 files changed, 34 insertions(+), 34 deletions(-) diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 35366dbaa69..4b096983a44 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -114,13 +114,13 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->decoder_->decode(param->notify.value, param->notify.value_len); if (this->decoder_->has_position()) { - this->position = ((float) this->decoder_->position_ / 100.0); + this->position = ((float) this->decoder_->position_ / 100.0f); if (!this->invert_position_) this->position = 1 - this->position; - if (this->position > 0.97) - this->position = 1.0; - if (this->position < 0.02) - this->position = 0.0; + if (this->position > 0.97f) + this->position = 1.0f; + if (this->position < 0.02f) + this->position = 0.0f; this->publish_state(); } diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index b7df603f2f4..642368d93fa 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -120,7 +120,7 @@ float BL0940::calculate_power_reference_() { float BL0940::calculate_energy_reference_() { // formula: 3600000 * 4046 * RL * R1 * 1000 / (1638.4 * 256) / Vref² / (R1 + R2) // or: power_reference_ * 3600000 / (1638.4 * 256) - return this->power_reference_cal_ * 3600000 / (1638.4 * 256); + return this->power_reference_cal_ * 3600000 / (1638.4f * 256); } float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; } diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 5a499d54a41..d15b310a37a 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -39,7 +39,7 @@ void CurrentBasedCover::control(const CoverCall &call) { auto opt_pos = call.get_position(); if (opt_pos.has_value()) { auto pos = *opt_pos; - if (fabsf(this->position - pos) < 0.01) { + if (fabsf(this->position - pos) < 0.01f) { // already at target } else { auto op = pos < this->position ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index 6846b8b663c..dea975a7702 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -9,7 +9,7 @@ namespace esphome::demo { class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { - bool initial = random_float() < 0.5; + bool initial = random_float() < 0.5f; this->publish_state(initial); } diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index bbd966fbe0e..892b67b270a 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -169,14 +169,14 @@ bool ES7210::configure_mic_gain_() { uint8_t ES7210::es7210_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) (mic_gain / 3); } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 5d59f605095..9ceb2f2ba01 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -139,7 +139,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo /// Draw grid if (!std::isnan(this->gridspacing_y_)) { for (int y = yn; y <= ym; y++) { - int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0 - (float) (y - yn) / (ym - yn))); + int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0f - (float) (y - yn) / (ym - yn))); for (uint32_t x = 0; x < this->width_; x += 2) { buff->draw_pixel_at(x_offset + x, y_offset + py, color); } @@ -177,7 +177,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo uint8_t bit = 1 << ((i % (thick * LineType::PATTERN_LENGTH)) / thick); bool b = (trace->get_line_type() & bit) == bit; if (b) { - int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0 - v)) - thick / 2 + y_offset; + int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0f - v)) - thick / 2 + y_offset; auto draw_pixel_at = [&buff, c, y_offset, this](int16_t x, int16_t y) { if (y >= y_offset && static_cast(y) < y_offset + this->height_) buff->draw_pixel_at(x, y, c); diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index bd5678a4258..a013371649a 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -341,7 +341,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 695de57c617..c22237d144b 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -70,7 +70,7 @@ void INA226Component::setup() { this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.00512 / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); + auto calibration = uint32_t(0.00512f / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index b7fad2e8767..0d43aac20e4 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -480,12 +480,12 @@ void LTRAlsPsComponent::apply_lux_calculation_(AlsReadings &data) { float inv_pfactor = this->glass_attenuation_factor_; float lux = 0.0f; - if (ratio < 0.45) { - lux = (1.7743 * ch0 + 1.1059 * ch1); - } else if (ratio < 0.64 && ratio >= 0.45) { - lux = (4.2785 * ch0 - 1.9548 * ch1); - } else if (ratio < 0.85 && ratio >= 0.64) { - lux = (0.5926 * ch0 + 0.1185 * ch1); + if (ratio < 0.45f) { + lux = (1.7743f * ch0 + 1.1059f * ch1); + } else if (ratio < 0.64f && ratio >= 0.45f) { + lux = (4.2785f * ch0 - 1.9548f * ch1); + } else if (ratio < 0.85f && ratio >= 0.64f) { + lux = (0.5926f * ch0 + 0.1185f * ch1); } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 5351d6a2cb9..33abbe847a3 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -31,7 +31,7 @@ float MCP3204::read_data(uint8_t pin, bool differential) { this->disable(); uint16_t digital_value = encode_uint16(b0, b1) >> 4; - return float(digital_value) / 4096.000 * this->reference_voltage_; // in V + return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } } // namespace esphome::mcp3204 diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index d7994327b1c..238e37aff02 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -75,16 +75,16 @@ void MPL3115A2Component::update() { float altitude = 0, pressure = 0; if (this->altitude_ != nullptr) { int32_t alt = encode_uint32(buffer[0], buffer[1], buffer[2], 0); - altitude = float(alt) / 65536.0; + altitude = float(alt) / 65536.0f; this->altitude_->publish_state(altitude); } else { uint32_t p = encode_uint32(0, buffer[0], buffer[1], buffer[2]); - pressure = float(p) / 6400.0; + pressure = float(p) / 6400.0f; if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } int16_t t = encode_uint16(buffer[3], buffer[4]); - float temperature = float(t) / 256.0; + float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 443c983efe5..d5ee4c6a9be 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -115,9 +115,9 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo // max_temp root[MQTT_MAX_TEMP] = traits.get_visual_max_temperature(); // target_temp_step - root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1; + root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step - root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1; + root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; // temperature units are always coerced to Celsius internally root[MQTT_TEMPERATURE_UNIT] = "C"; diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a332d342ee5..a356d54e2f1 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -176,7 +176,7 @@ void Nextion::goto_page(const char *page) { this->add_no_result_to_queue_with_pr void Nextion::goto_page(uint8_t page) { this->add_no_result_to_queue_with_printf_("page", "page %i", page); } void Nextion::set_backlight_brightness(float brightness) { - if (brightness < 0 || brightness > 1.0) { + if (brightness < 0 || brightness > 1.0f) { ESP_LOGD(TAG, "Brightness out of bounds (0-1.0)"); return; } diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 1988f574db8..3672d164c4d 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -300,7 +300,7 @@ bool PIDAutotuner::OscillationFrequencyDetector::is_increase_decrease_symmetrica min_interval = std::min(min_interval, interval); } float ratio = min_interval / float(max_interval); - return ratio >= 0.66; + return ratio >= 0.66f; } // ================== OscillationAmplitudeDetector ================== diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index d2028ce9bdf..8d5344cf440 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -86,7 +86,7 @@ void Servo::write(float value) { void Servo::internal_write(float value) { value = clamp(value, -1.0f, 1.0f); float level; - if (value < 0.0) { + if (value < 0.0f) { level = std::lerp(this->idle_level_, this->min_level_, -value); } else { level = std::lerp(this->idle_level_, this->max_level_, value); diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 7d9cfecfdfa..fe994f440df 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index fd6cf1e2ede..59892936b03 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -215,7 +215,7 @@ void VEML3235Sensor::dump_config() { " Auto-gain upper threshold: %f%%\n" " Auto-gain lower threshold: %f%%\n" " Values below will be used as initial values only", - this->auto_gain_threshold_high_ * 100.0, this->auto_gain_threshold_low_ * 100.0); + this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } ESP_LOGCONFIG(TAG, " Digital gain: %uX\n" diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 1cf0de14d36..958ac59bde4 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -49,7 +49,7 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; From 2f32c88ae51d29d52dc508543792434d0ddaf2d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:16:42 -0400 Subject: [PATCH 201/343] [wifi] Fix crash when WiFi is enabled late alongside ESP-NOW (#17239) --- .../wifi/wifi_component_esp_idf.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index b395c771414..2ade015a255 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() { // nor re-register the default WiFi handlers. if (s_sta_netif == nullptr) s_sta_netif = esp_netif_create_default_wifi_sta(); + if (s_sta_netif == nullptr) { + // Allocation failed; leave wifi_initialized_ false so a later enable() retries. + ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed"); + return; + } #ifdef USE_WIFI_AP if (s_ap_netif == nullptr) s_ap_netif = esp_netif_create_default_wifi_ap(); #endif // USE_WIFI_AP + // The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at + // boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler + // therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the + // only thing that points the driver's RX path at a netif (it sets + // s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the + // immediate crash (#17232) but leaves RX unbound, so the first association + // associates at L2 yet never receives DHCP replies and times out (#17239). Restart + // the driver now that the netif exists so STA_START re-runs the default handler and + // wires RX correctly. ESP-NOW survives the stop/start (its peer state persists). + // This also matches a self-retry: if esp_wifi_set_storage() below failed on a + // previous wifi_lazy_init_() it returned without setting wifi_initialized_, and + // esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too. + wifi_mode_t mode; + if (esp_wifi_get_mode(&mode) == ESP_OK) { + ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it"); + esp_err_t err = esp_wifi_stop(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err)); + } + // Re-apply RAM storage; the normal init path does this, but it is skipped on + // the self-retry case above, which would otherwise let the driver persist + // credentials to NVS for the rest of the boot. + err = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); + } + err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err)); + return; + } + s_wifi_started = true; + this->wifi_initialized_ = true; + return; + } + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); if (global_preferences->nvs_handle == 0) { ESP_LOGW(TAG, "starting wifi without nvs"); From 4ebecf514a6ef85f36aa9af99c490dded191ad1d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 28 Jun 2026 12:41:47 -0700 Subject: [PATCH 202/343] [modbus_server] Simplify server response handling (#12376) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/modbus/__init__.py | 1 - esphome/components/modbus/modbus.cpp | 161 +++++++++++++---- esphome/components/modbus/modbus.h | 60 ++++--- .../components/modbus/modbus_definitions.h | 2 +- esphome/components/modbus/modbus_helpers.cpp | 74 ++++---- esphome/components/modbus/modbus_helpers.h | 71 +++++++- .../modbus_server/modbus_server.cpp | 167 ++++++------------ .../components/modbus_server/modbus_server.h | 6 +- .../components/modbus/modbus_helpers_test.cpp | 36 ++++ .../modbus_server/modbus_server_test.cpp | 124 +++++++++++++ 10 files changed, 476 insertions(+), 226 deletions(-) create mode 100644 tests/components/modbus_server/modbus_server_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 492dfcaafea..cf1d4093936 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -124,7 +124,6 @@ async def register_modbus_client_device(var, config): async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9ba2e837e0..5b771c62826 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -258,7 +258,7 @@ bool ModbusServerHub::parse_modbus_client_frame_() { std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); - this->process_modbus_client_frame_(address, function_code, data, data_len); + this->process_modbus_client_frame_(address, function_code, data); return true; } @@ -321,10 +321,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct } void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { - for (auto *device : this->devices_) { - if (device->address_ == address) { - ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); - } + if (this->find_device_(address) != nullptr) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); } if (this->expecting_peer_response_ == address) { @@ -338,31 +336,124 @@ void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t funct this->expecting_peer_response_ = 0; } -void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, - uint16_t len) { - bool found = false; - +ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - - if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || - static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), - helpers::get_data(data, 2)); - } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, std::vector(data, data + len)); - } else { - ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); - device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - } + if (device->get_address() == address) { + return device; } } + return nullptr; +} - if (!found) { +bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + if ((uint32_t) start_address + number_of_registers > 0x10000u) { + ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, + number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return false; + } + return true; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { + ModbusServerDevice *device = this->find_device_(address); + if (device == nullptr) { this->expecting_peer_response_ = address; ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + return; + } + + ServerResponseStatus status; + uint8_t response_buffer[modbus::MAX_RAW_SIZE]; + const uint8_t *response_data = response_buffer; + uint16_t response_len = 0; + + switch (static_cast(function_code)) { + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: { + // PDU data: start address(2) + quantity(2). + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = helpers::get_data(data, 2); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + RegisterValues registers; + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { + status = device->on_modbus_read_holding_registers(start_address, number_of_registers, registers); + } else { + status = device->on_modbus_read_input_registers(start_address, number_of_registers, registers); + } + + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; + } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } + break; + } + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: { + // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. + // A single-register write always targets one register; for a multiple-register write the + // quantity is in the frame and its byte count must equal quantity * 2. The register values are + // assembled into registers below so the handler doesn't have to know the request framing. + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = 1; + uint16_t values_offset = 2; // single write: values follow the 2-byte start address + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + number_of_registers = helpers::get_data(data, 2); + uint8_t number_of_bytes = helpers::get_data(data, 4); + values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, + number_of_bytes); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + } + // Assemble the register values (host byte order) so the handler never sees wire framing. + RegisterValues registers; + for (uint16_t i = 0; i < number_of_registers; i++) { + registers.push_back(helpers::get_data(data, values_offset + i * 2)); + } + status = device->on_modbus_write_registers(start_address, registers); + response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_len = 4; + break; + } + default: + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + } else { + this->send_response_(address, function_code, response_data, response_len); } } @@ -455,17 +546,27 @@ float Modbus::get_setup_priority() const { return setup_priority::BUS - 1.0f; } -void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { - const uint16_t len = static_cast(2 + payload.size()); - if (len > MAX_RAW_SIZE) { - ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); +void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, + uint16_t payload_len) { + // Build the raw frame (address + function code + payload) in a stack buffer; it's consumed + // immediately by send_raw_ and a full raw frame never exceeds MAX_RAW_SIZE. + if (payload_len + 2 > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server response too large (%" PRIu16 " bytes)", static_cast(payload_len + 2)); return; } uint8_t raw_frame[MAX_RAW_SIZE]; raw_frame[0] = address; raw_frame[1] = function_code; - std::memcpy(raw_frame + 2, payload.data(), payload.size()); - this->send_raw_(raw_frame, len); + std::memcpy(raw_frame + 2, payload, payload_len); + this->send_raw_(raw_frame, payload_len + 2); +} + +void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t raw_frame[3]; + raw_frame[0] = address; + raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK; + raw_frame[2] = static_cast(exception_code); + this->send_raw_(raw_frame, 3); } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index da0db13a074..95b7a770b68 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -130,22 +130,22 @@ class ModbusServerHub : public Modbus { public: ModbusServerHub() = default; void dump_config() override; - void send(uint8_t address, uint8_t function_code, const std::vector &payload); - ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") - void send_raw(const std::vector &payload) { - this->send_raw_(payload.data(), static_cast(payload.size())); - }; void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend class ModbusServerDevice; - void parse_modbus_frames() override; bool parse_modbus_client_frame_(); // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; - void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); + ModbusServerDevice *find_device_(uint8_t address); + // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. + // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. + bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers); void send_raw_(const uint8_t *payload, uint16_t len); + void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code); + void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; std::vector devices_; @@ -200,35 +200,41 @@ class ModbusClientDevice { // This is for compatibility with external components using the former class name using ModbusDevice = ModbusClientDevice; +// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. +using ServerResponseStatus = std::optional; +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + class ModbusServerDevice { public: - ModbusServerDevice() = default; - ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} virtual ~ModbusServerDevice() = default; + ModbusServerDevice() = default; + // Polymorphic base: non-copyable and non-movable to prevent slicing (Rule of Five). ModbusServerDevice(const ModbusServerDevice &) = delete; ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; ModbusServerDevice(ModbusServerDevice &&) = delete; ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; - void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } void set_address(uint8_t address) { this->address_ = address; } - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, const std::vector &payload) { - this->parent_->send(this->address_, function, payload); - } - void send_raw(const std::vector &payload) { - this->parent_->send_raw_(payload.data(), static_cast(payload.size())); - } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), - static_cast(exception_code)}; - this->parent_->send_raw_(error_response, 3); - } + uint8_t get_address() const { return this->address_; } + virtual ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ServerResponseStatus on_modbus_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_modbus_read_registers(start_address, number_of_registers, registers); + }; + virtual ServerResponseStatus on_modbus_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_modbus_read_registers(start_address, number_of_registers, registers); + }; + virtual ServerResponseStatus on_modbus_write_registers(uint16_t start_address, const RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; protected: - friend ModbusServerHub; - - ModbusServerHub *parent_{nullptr}; uint8_t address_{0}; }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 49172b9dca4..1c03498f1da 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -82,7 +82,7 @@ static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 -static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 256 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 4cddfca104a..53fa6afacb7 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -101,53 +101,19 @@ static size_t required_payload_size(SensorValueType sensor_value_type) { } } -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - switch (value_type) { - case SensorValueType::U_WORD: - case SensorValueType::S_WORD: - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD: - case SensorValueType::S_DWORD: - case SensorValueType::FP32: - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::S_DWORD_R: - case SensorValueType::FP32_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - data.push_back((value & 0xFFFF000000000000) >> 48); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF000000000000) >> 48); - break; - default: - ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); - break; - } +void log_unsupported_value_type(SensorValueType value_type) { + ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, +int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so // a malformed or misconfigured frame still produces an error log. - if (static_cast(offset) > data.size()) { + if (static_cast(offset) > size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), - static_cast(offset), data.size()); + static_cast(offset), size); if (error_return) *error_return = true; return value; @@ -158,10 +124,9 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } - if (data.size() - offset < required_size) { + if (size - offset < required_size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", - static_cast(sensor_value_type), static_cast(offset), data.size(), - required_size); + static_cast(sensor_value_type), static_cast(offset), size, required_size); if (error_return) *error_return = true; return value; @@ -214,6 +179,31 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } +int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, + bool *error_return) { + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return 0; // RAW/unsupported: nothing to read + } + const size_t required_words = required_size / 2; + if (required_words > count) { + ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", + static_cast(sensor_value_type), count, required_words); + if (error_return) + *error_return = true; + return 0; + } + // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the + // sign-extension behaviour stays identical to the wire path. + uint8_t bytes[8]; // at most 4 registers (QWORD) + for (size_t i = 0; i < required_words; i++) { + uint16_t reg = registers[i]; + bytes[i * 2] = static_cast(reg >> 8); + bytes[i * 2 + 1] = static_cast(reg & 0xFF); + } + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return); +} + StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, uint16_t number_of_entities, const uint8_t *values, size_t values_len) { diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b637d872cf7..b7b9020945a 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -224,24 +224,77 @@ template N mask_and_shift_by_rightbit(N data, uint32_t mask) { return 0; } -/** Convert float value to vector suitable for sending - * @param data target for payload - * @param value float value to convert - * @param value_type defines if 16/32 or FP32 is used - * @return vector containing the modbus register words in correct order - */ -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); +// Logs an error for an unsupported value type. Defined in the .cpp so logging stays out of headers. +void log_unsupported_value_type(SensorValueType value_type); -/** Convert vector response payload to number. +/** Append the Modbus register words for value to data. + * Works with any container exposing push_back(uint16_t) (e.g. std::vector or StaticVector). + */ +template void number_to_payload(Container &data, int64_t value, SensorValueType value_type) { + switch (value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::FP32: + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + data.push_back((value & 0xFFFF000000000000) >> 48); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF000000000000) >> 48); + break; + default: + log_unsupported_value_type(value_type); + break; + } +} + +/** Convert a raw response payload to a number. * @param data payload with the data to convert + * @param size number of bytes available in data * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @param offset offset to the data in data * @param bitmask bitmask used for masking and shifting * @return 64-bit number of the payload */ -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, +int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask, bool *error_return = nullptr); +/** Convert vector response payload to number. */ +inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask, bool *error_return = nullptr) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return); +} + +/** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. + * Decodes the value at the start of the given span; advance the pointer to read successive values. + * @param registers register values in host byte order + * @param count number of registers available in registers + * @param sensor_value_type defines if 16/32/64 bits or FP32 is used + * @return 64-bit number of the registers + */ +int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, + bool *error_return = nullptr); + /** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. * @param function_code the modbus function code to use. One of: * READ_COILS diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index c294d088889..bb264eb9933 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -3,26 +3,18 @@ #include "esphome/core/log.h" namespace esphome::modbus_server { -using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; -using modbus::helpers::payload_to_number; +using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; -void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { +modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, + uint16_t number_of_registers, + modbus::RegisterValues ®isters) { ESP_LOGV(TAG, - "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); + "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", + this->address_, start_address, number_of_registers); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - std::vector sixteen_bit_response; for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { bool found = false; for (auto *server_register : this->server_registers_) { @@ -36,10 +28,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star server_register->address, static_cast(server_register->value_type), server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); - std::vector payload; - payload.reserve(server_register->register_count * 2); - modbus::helpers::number_to_payload(payload, value, server_register->value_type); - sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); + modbus::helpers::number_to_payload(registers, value, server_register->value_type); current_address += server_register->register_count; found = true; break; @@ -53,92 +42,37 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star "Could not match any register to address 0x%02X, but default allowed. " "Returning default value: %" PRIu16 ".", current_address, this->server_courtesy_response_.register_value); - sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); + registers.push_back(this->server_courtesy_response_.register_value); current_address += 1; // Just increment by 1, as the default response is a single register } else { ESP_LOGW(TAG, "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", current_address); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } } } - std::vector response; - if (number_of_registers != sixteen_bit_response.size()) - ESP_LOGW(TAG, "Response size not matched to request register count."); - response.push_back(sixteen_bit_response.size() * 2); // actual byte count - for (auto v : sixteen_bit_response) { - auto decoded_value = decode_value(v); - response.push_back(decoded_value[0]); - response.push_back(decoded_value[1]); - } - this->send(function_code, response); + return {}; } -void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; +modbus::ServerResponseStatus ModbusServer::on_modbus_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + // registers holds the values to write in host byte order; its size is the register count. + ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", + this->address_, start_address, registers.size()); - if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (data.size() < 5) { - ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - uint16_t payload_size = data[4]; - if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, - "Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 - "). Sending exception response.", - payload_size, number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (data.size() < 5 + payload_size) { - ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), - 5 + payload_size); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - payload_offset = 5; - } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - if (data.size() < 4) { - ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = 1; - payload_offset = 2; - } else { - ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); - ESP_LOGD(TAG, - "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - auto for_each_register = [this, start_address, number_of_registers, payload_offset]( - const std::function &callback) -> bool { - uint16_t offset = payload_offset; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + auto for_each_register = + [this, start_address, + ®isters](const std::function &callback) -> bool { + uint16_t register_offset = 0; + for (uint32_t current_address = start_address; current_address < start_address + registers.size();) { bool ok = false; for (auto *server_register : this->server_registers_) { if (server_register->address == current_address) { - ok = callback(server_register, offset); + ok = callback(server_register, register_offset); current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); + register_offset += server_register->register_count; break; } } @@ -150,36 +84,41 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return true; }; - // check all registers are writable before writing to any of them: - if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { - return server_register->write_lambda != nullptr; - })) { - ESP_LOGW(TAG, "Invalid register address. Sending exception response."); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - // Actually write to the registers: - if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - bool error = false; - int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); - if (error) { - return false; - } else { - return server_register->write_lambda(number); + // Pre-flight: every targeted register must be writable AND have its full value present in the request, + // so we never apply a partial write before discovering a problem. The commit pass below re-runs + // registers_to_number rather than caching the decoded values: using the same function for the check and + // the write keeps a single source of truth for the decode bound, independent of how register_count was set. + ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register + if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool { + if (server_register->write_lambda == nullptr) { + return false; // unwritable -> ILLEGAL_DATA_ADDRESS } + bool error = false; + registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type, &error); + if (error) { + precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value + return false; + } + return true; })) { - ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; + ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + return precheck; } - std::vector response; - response.reserve(6); - response.push_back(this->address_); - response.push_back(function_code); - response.insert(response.end(), data.begin(), data.begin() + 4); - this->send_raw(response); + // Commit: every value is known writable and decodable, so the only failure now is a user write callback + // rejecting the value at runtime -- which cannot be rolled back. + if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { + int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type); + return server_register->write_lambda(number); + })) { + ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + } + + // Success: the caller builds the write response (an echo of the request header). + return {}; } void ModbusServer::dump_config() { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index fa1376542c2..0c224545286 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -98,9 +98,11 @@ class ModbusServer : public Component, public modbus::ModbusServerDevice { /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; + modbus::ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; + modbus::ServerResponseStatus on_modbus_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index cd260f410a5..ecdca4df6dc 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -194,4 +194,40 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } +// --- registers_to_number --------------------------------------------------- +// Register words are host byte order; results must match the byte-based payload_to_number. + +TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) { + const uint16_t registers[] = {0x1234}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) { + const uint16_t registers[] = {0x1234, 0x5678}; + EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesAtSpanStart) { + // The function decodes the value at the start of the span; the caller advances the pointer. + const uint16_t registers[] = {0xAAAA, 0x1234}; + EXPECT_EQ(registers_to_number(registers + 1, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { + // Same value via both decoders: registers (host order) vs big-endian bytes. + const uint16_t registers[] = {0x8001, 0x0002}; + const std::vector bytes{0x80, 0x01, 0x00, 0x02}; + for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { + EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF)) + << "value_type=" << static_cast(value_type); + } +} + +TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { + const uint16_t registers[] = {0x1234}; + bool error = false; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0); + EXPECT_TRUE(error); +} + } // namespace esphome::modbus::helpers diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp new file mode 100644 index 00000000000..0c8f5d04cf0 --- /dev/null +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -0,0 +1,124 @@ +#include + +#include "esphome/components/modbus_server/modbus_server.h" + +namespace esphome::modbus_server { + +using modbus::ModbusExceptionCode; +using modbus::RegisterValues; + +namespace { + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +} // namespace + +// A single writable WORD register is applied and the handler reports success (nullopt). +TEST(ModbusServerWrite, SingleWordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + EXPECT_FALSE(status.has_value()); // nullopt == success + EXPECT_EQ(written, 0x1234); +} + +// A multi-register value is decoded high word first and applied as a single number. +TEST(ModbusServerWrite, DwordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(written, 0x12345678); +} + +// Regression: a request that under-supplies a multi-register value is rejected before any +// write_lambda runs, so no register is partially written. +TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { + ModbusServer server; + bool word_written = false; + ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1); + word_reg.write_lambda = [&word_written](int64_t) { + word_written = true; + return true; + }; + bool dword_written = false; + ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers + dword_reg.write_lambda = [&dword_written](int64_t) { + dword_written = true; + return true; + }; + server.add_server_register(&word_reg); + server.add_server_register(&dword_reg); + + // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); + EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied + EXPECT_FALSE(dword_written); +} + +// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing. +TEST(ModbusServerWrite, UnwritableRegisterRejected) { + ModbusServer server; + ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set + server.add_server_register(&read_only); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerWrite, UnmatchedAddressRejected) { + ModbusServer server; + auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already +// applied, and the handler reports SERVICE_DEVICE_FAILURE. +TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerRegister first(0x0000, SensorValueType::U_WORD, 1); + first.write_lambda = [&first_written](int64_t) { + first_written = true; + return true; + }; + ServerRegister second(0x0001, SensorValueType::U_WORD, 1); + second.write_lambda = [](int64_t) { return false; }; // rejects at runtime + server.add_server_register(&first); + server.add_server_register(&second); + + auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure +} + +} // namespace esphome::modbus_server From b62f7a41c92bca055ace95fce303db06b1dbf64c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:15:38 -0400 Subject: [PATCH 203/343] [multiple] Single-precision float math, avoid double promotion (stragglers) (#17260) --- esphome/components/ac_dimmer/ac_dimmer.cpp | 2 +- esphome/components/display/display.cpp | 14 +++++++------- esphome/components/hmc5883l/hmc5883l.cpp | 4 +++- esphome/components/mmc5603/mmc5603.cpp | 4 +++- esphome/components/pid/pid_autotuner.cpp | 7 ++----- esphome/components/qmc5883l/qmc5883l.cpp | 3 ++- esphome/components/rd03d/rd03d.cpp | 3 ++- esphome/components/sx126x/sx126x.cpp | 2 +- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 3e21d6981d7..477962a0404 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -216,7 +216,7 @@ void AcDimmer::setup() { } void AcDimmer::write_state(float state) { - state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation + state = std::acos(1 - (2 * state)) / std::numbers::pi_v; // RMS power compensation auto new_value = static_cast(roundf(state * 65535)); if (new_value != 0 && this->store_.value == 0) this->store_.init_cycle = this->init_with_half_cycle_; diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b30f444d6d8..115adf503a5 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -42,10 +42,10 @@ void Display::line_at_angle(int x, int y, int angle, int length, Color color) { void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_radius, Color color) { // Calculate start and end points - int x1 = (start_radius * cos(angle * M_PI / 180)) + x; - int y1 = (start_radius * sin(angle * M_PI / 180)) + y; - int x2 = (stop_radius * cos(angle * M_PI / 180)) + x; - int y2 = (stop_radius * sin(angle * M_PI / 180)) + y; + int x1 = (start_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y1 = (start_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; + int x2 = (stop_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y2 = (stop_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; // Draw line this->line(x1, y1, x2, y2, color); @@ -444,15 +444,15 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * // hence we rotate the shape by 270° to orient the polygon up. rotation_degrees += ROTATION_270_DEGREES; // Convert the rotation to radians, easier to use in trigonometrical calculations - float rotation_radians = rotation_degrees * std::numbers::pi / 180; + float rotation_radians = rotation_degrees * std::numbers::pi_v / 180; // A pointy top variation means the first vertex of the polygon is at the top center of the shape, this requires no // additional rotation of the shape. // A flat top variation means the first point of the polygon has to be rotated so that the first edge is horizontal, // this requires to rotate the shape by π/edges radians counter-clockwise so that the first point is located on the // left side of the first horizontal edge. - rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; + rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi_v / edges : 0.0f; - float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; + float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi_v + rotation_radians; *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index 7930df7a38c..c6b7da66105 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" +#include + namespace esphome::hmc5883l { static const char *const TAG = "hmc5883l"; @@ -126,7 +128,7 @@ void HMC5883LComponent::update() { const float y = int16_t(raw_y) * mg_per_bit * 0.1f; const float z = int16_t(raw_z) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 79c580c6b78..15e715e6751 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -1,6 +1,8 @@ #include "mmc5603.h" #include "esphome/core/log.h" +#include + namespace esphome::mmc5603 { static const char *const TAG = "mmc5603"; @@ -143,7 +145,7 @@ void MMC5603Component::update() { const float z = 0.00625 * (raw_z - 524288); - const float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + const float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 3672d164c4d..a7ae631956a 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -1,10 +1,7 @@ #include "pid_autotuner.h" #include "esphome/core/log.h" #include - -#ifndef M_PI -#define M_PI 3.1415926535897932384626433 -#endif +#include namespace esphome::pid { @@ -126,7 +123,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce float osc_ampl = this->amplitude_detector_.get_mean_oscillation_amplitude(); float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f; ESP_LOGVV(TAG, " Relay magnitude: %f", d); - this->ku_ = 4.0f * d / float(M_PI * osc_ampl); + this->ku_ = 4.0f * d / (std::numbers::pi_v * osc_ampl); this->pu_ = this->frequency_detector_.get_mean_oscillation_period(); this->state_ = AUTOTUNE_SUCCEEDED; diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index 5b04a904b54..ba6a71f97d1 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::qmc5883l { @@ -173,7 +174,7 @@ void QMC5883LComponent::read_sensor_() { const float y = int16_t(raw[1]) * mg_per_bit * 0.1f; const float z = int16_t(raw[2]) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; float temp = NAN; if (this->temperature_sensor_ != nullptr) { diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index c9c6a546ab8..2eb76a10873 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace esphome::rd03d { @@ -233,7 +234,7 @@ void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, i // Angle is measured from the Y axis (radar forward direction) if (target.angle != nullptr) { if (valid) { - float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / M_PI; + float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / std::numbers::pi_v; target.angle->publish_state(angle); } else { target.angle->publish_state(NAN); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index af42c63bf41..376676ce850 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -215,7 +215,7 @@ void SX126x::configure() { // configure modem if (this->modulation_ == PACKET_TYPE_LORA) { // set modulation params - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; buf[0] = this->spreading_factor_; buf[1] = BW_LORA[this->bandwidth_ - SX126X_BW_7810]; buf[2] = this->coding_rate_; From 8434d54cc785808494b1d0834a4611cc6896e83c Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 28 Jun 2026 14:07:25 -0700 Subject: [PATCH 204/343] [modbus] Reinstate turnaround delay after broadcasts (Revert #17209) (#17263) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/modbus/modbus.cpp | 16 ++---- esphome/components/modbus/modbus.h | 1 - .../fixtures/uart_mock_modbus_broadcast.yaml | 56 ------------------- tests/integration/test_uart_mock_modbus.py | 25 --------- 4 files changed, 5 insertions(+), 93 deletions(-) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5b771c62826..488bcf14592 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -92,14 +92,10 @@ int32_t Modbus::tx_delay_remaining() { int32_t ModbusClientHub::tx_delay_remaining() { const uint32_t now = millis(); - // Turnaround delay only applies after a broadcast: no response is expected, so we must give listening devices - // quiet time to process it before the next request. For normal unicast request/response the received reply already - // provides the inter-frame timing, so adding turnaround there just throttles throughput. - const uint16_t turnaround = this->last_send_was_broadcast_ ? this->turnaround_delay_ms_ : 0; - return std::max( - {(int32_t) 0, - (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + turnaround - (now - this->last_send_)), - (int32_t) (this->frame_delay_ms_ + turnaround - (now - this->last_modbus_byte_))}); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { @@ -491,7 +487,6 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; - this->last_send_was_broadcast_ = frame.size > 0 && frame.data[0] == 0; return true; } @@ -507,8 +502,7 @@ void ModbusClientHub::send_next_frame_() { ModbusDeviceCommand &command = this->tx_buffer_.front(); if (this->send_frame_(command.frame)) { - if (!this->last_send_was_broadcast_) - this->waiting_for_response_ = std::move(command); + this->waiting_for_response_ = std::move(command); } else { if (command.device) command.device->on_modbus_not_sent(); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 95b7a770b68..4aa3a16c3a7 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -63,7 +63,6 @@ class Modbus : public uart::UARTDevice, public Component { uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; - bool last_send_was_broadcast_{false}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml deleted file mode 100644 index a5ce02b3428..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_broadcast.yaml +++ /dev/null @@ -1,56 +0,0 @@ -esphome: - name: uart-mock-modbus-bcast - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -# No on_tx injection: a broadcast (address 0) gets no reply on a real bus. -uart_mock: - - id: virtual_uart - baud_rate: 9600 - auto_start: true - debug: - -modbus: - - uart_id: virtual_uart - id: virtual_modbus - role: client - send_wait_time: 200ms - turnaround_time: 10ms - -modbus_controller: - - address: 0 - modbus_id: virtual_modbus - update_interval: 60s - id: modbus_controller_bcast - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_bcast - id: bcast_write - name: "bcast_write" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - -interval: - - interval: 400ms - then: - - number.set: - id: bcast_write - value: 42 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 385707d8492..2c437341c6c 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,28 +330,3 @@ async def test_uart_mock_modbus_server_controller_multiple( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) - - -@pytest.mark.asyncio -async def test_uart_mock_modbus_broadcast( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that broadcast writes (address 0) don't wait for a response. - - A controller at address 0 sends broadcast writes that get no reply. The - client must not arm the response timeout for them: otherwise every write - blocks for send_wait_time and logs a spurious "no response from 0" warning. - """ - - line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - - async with ( - run_compiled(yaml_config, line_callback=line_callback), - api_client_connected(), - ): - # Several broadcast writes fire on the 400ms interval; send_wait_time is - # 200ms, so the old behaviour would have warned on each one by now. - await asyncio.sleep(3.0) - _assert_no_modbus_errors(error_log_lines, warning_log_lines) From a336ad6732ef0ddf5f76abacc0b52e2b8566102c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Jun 2026 14:07:51 -0700 Subject: [PATCH 205/343] [mcp4725] Use constexpr bit shift instead of powf for full-scale value (#17261) --- esphome/components/mcp4725/mcp4725.cpp | 3 ++- esphome/components/mcp4725/mcp4725.h | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index 21aff90fae0..8e94623de36 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,8 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) roundf(state * (powf(2, MCP4725_RES) - 1)); + constexpr uint16_t max_value = (1U << MCP4725_RES) - 1; + const uint16_t value = (uint16_t) roundf(state * max_value); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 4f1f128e52b..a0838dc33e6 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -4,10 +4,11 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -static const uint8_t MCP4725_ADDR = 0x60; -static const uint8_t MCP4725_RES = 12; - namespace esphome::mcp4725 { + +static constexpr uint8_t MCP4725_ADDR = 0x60; +static constexpr uint8_t MCP4725_RES = 12; + class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; From 5f311d281e78ef38b621eddfc41ab1459754afe1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:17:01 +1000 Subject: [PATCH 206/343] [esphome] Warn when a YAML merge (`<<:`) drops a key (#17246) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/config.py | 19 +++++++ esphome/const.py | 1 + esphome/core/config.py | 2 + esphome/yaml_util.py | 27 +++++++++ script/ci-custom.py | 2 +- tests/unit_tests/test_config_normalization.py | 55 +++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 45 +++++++++++++++ 7 files changed, 150 insertions(+), 1 deletion(-) diff --git a/esphome/config.py b/esphome/config.py index 33e687137f2..fc8f46909f1 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_ID, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_PACKAGES, CONF_PLATFORM, @@ -1184,6 +1185,24 @@ def validate_config( ) return result + # Warn about any keys silently dropped by `<<` merge includes (shallow, + # first-wins). The esphome: section is now known, so we can honor its + # `merge_warnings:` opt-out. Always drain the queue to keep it from leaking + # into a later run. + if (dropped := yaml_util.take_dropped_merge_keys()) and ( + not isinstance(esphome_conf := config[CONF_ESPHOME], dict) + or esphome_conf.get(CONF_MERGE_WARNINGS, True) + ): + for key, location in dict.fromkeys(dropped): + _LOGGER.warning( + "Key '%s' (%s) was dropped while processing a '<<' merge because it " + "is already defined. Merge keys don't combine sections - the first " + "definition wins. Use 'packages:' to merge sections, or set " + "'esphome: { merge_warnings: false }' to silence this.", + key, + location, + ) + # Snapshot the user's config before any schema validation defaults are # applied. preload_core_config and later validation steps rewrite entries # in-place with defaulted values; deep-copying here preserves the diff --git a/esphome/const.py b/esphome/const.py index 3ca7b2e6188..5fa6f00b59e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -613,6 +613,7 @@ CONF_MEASUREMENT_SEQUENCE_NUMBER = "measurement_sequence_number" CONF_MEDIA_PLAYER = "media_player" CONF_MEDIUM = "medium" CONF_MEMORY_BLOCKS = "memory_blocks" +CONF_MERGE_WARNINGS = "merge_warnings" CONF_MESSAGE = "message" CONF_METHANE = "methane" CONF_METHOD = "method" diff --git a/esphome/core/config.py b/esphome/core/config.py index 59c96035b8f..0670fde0ff2 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_INCLUDES, CONF_INCLUDES_C, CONF_LIBRARIES, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, @@ -316,6 +317,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, + cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index bfe1fb01364..0009cde5514 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -51,6 +51,29 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +# Key under CORE.data used to accumulate keys that a `<<` merge silently +# dropped. The warning is emitted later (see esphome.config.validate_config), +# because the esphome: option that suppresses it isn't known while parsing. +_MERGE_WARNINGS_KEY = "yaml_dropped_merge_keys" + + +def _record_dropped_merge_key(parent_file: Path, key: Any) -> None: + """Record a mapping key that a ``<<`` merge silently dropped. + + Merge keys follow the YAML spec's shallow, first-wins semantics: a key that + already exists in the mapping (or came from an earlier merge) is discarded + rather than deep-merged the way ``packages:`` would combine it. We collect + these so a single warning can be emitted once the config is loaded. + """ + esp_range = getattr(key, "esp_range", None) + location = str(esp_range.start_mark) if esp_range is not None else str(parent_file) + CORE.data.setdefault(_MERGE_WARNINGS_KEY, []).append((str(key), location)) + + +def take_dropped_merge_keys() -> list[tuple[str, str]]: + """Return and clear the keys dropped during ``<<`` merges so far.""" + return CORE.data.pop(_MERGE_WARNINGS_KEY, []) + class SensitiveStr(str): """Marker subclass for validated strings that should be masked in @@ -551,6 +574,10 @@ class ESPHomeLoaderMixin: # is expected to contain mapping nodes and each of these nodes is merged in # turn according to its order in the sequence. Keys in mapping nodes earlier # in the sequence override keys specified in later mapping nodes." + # + # This is a silent shallow drop (unlike `packages:`, which deep-merges). + # Record it so a warning can be emitted after the config loads. + _record_dropped_merge_key(self.name, key) continue pairs.append((key, value)) # Add key node to seen keys, for sequence merge values. diff --git a/script/ci-custom.py b/script/ci-custom.py index 6c5ad5bb69f..4568732b882 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -555,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1013 +CONST_PY_MAX_CONF = 1014 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index 4ec17b3c7cd..a06b2da6217 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,6 +1,7 @@ """Unit tests for esphome.config module.""" from collections.abc import Generator +import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -113,3 +114,57 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: platforms = {p.get("platform") for p in result["ota"]} assert "esphome" in platforms, f"Expected esphome platform in {platforms}" assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: + """Create a config where two `<<` includes both define `logger:`. + + The second `logger:` is dropped by the shallow merge. Returns the main file. + """ + (tmp_path / "a.yaml").write_text("logger:\n level: DEBUG\n") + (tmp_path / "b.yaml").write_text("logger:\n level: INFO\n") + esphome_section = "esphome:\n name: test\n" + if suppress: + esphome_section += " merge_warnings: false\n" + main = tmp_path / "main.yaml" + main.write_text(f"{esphome_section}<<: !include a.yaml\n<<: !include b.yaml\n") + return main + + +def test_validate_config_warns_on_dropped_merge_key( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """By default, a `<<` merge that drops a key logs a warning.""" + main = _write_merge_conflict_config(tmp_path, suppress=False) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert any( + "was dropped while processing a '<<' merge" in record.message + and "logger" in record.message + for record in caplog.records + ) + # The queue is drained so the warning cannot leak into a later run. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_validate_config_suppresses_merge_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """`esphome: merge_warnings: false` hides the warning but still drains the queue.""" + main = _write_merge_conflict_config(tmp_path, suppress=True) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert not any( + "was dropped while processing a '<<' merge" in record.message + for record in caplog.records + ) + # The queue is drained even when the warning is suppressed. + assert yaml_util.take_dropped_merge_keys() == [] diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 6be090b869c..fa1c0fcce21 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1395,3 +1395,48 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted assert "\\033[8m" not in raw assert "\\033[8m" in redacted_again + + +@pytest.fixture(autouse=True) +def clear_dropped_merge_keys() -> None: + """Reset the dropped-merge-key queue between tests.""" + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + yield + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + + +def test_merge_include_records_dropped_keys(tmp_path: Path) -> None: + """A `<<` merge that overlaps an existing key records it (shallow first-wins).""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("api:\n password: secret\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + # First definition wins; the second `api` block is dropped entirely. + assert result["api"] == {"reboot_timeout": "5min"} + + dropped = yaml_util.take_dropped_merge_keys() + assert len(dropped) == 1 + key, location = dropped[0] + assert key == "api" + assert "b.yaml" in location + # Queue is drained after being taken. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: + """A `<<` merge with distinct top-level keys drops nothing.""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + assert result["api"] == {"reboot_timeout": "5min"} + assert result["logger"] == {"level": "DEBUG"} + assert yaml_util.take_dropped_merge_keys() == [] From 9e8261056cae22002ab84f245faef71540bae01e Mon Sep 17 00:00:00 2001 From: Tom <7723105+thomasfw@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:06:12 +0100 Subject: [PATCH 207/343] [espnow] Fix espnow crash when send() is called without a callback (#17266) --- esphome/components/espnow/espnow_component.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f89b4a2ff1b..2756b615a13 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -303,7 +303,9 @@ void ESPNowComponent::loop() { ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); #endif if (this->current_send_packet_ != nullptr) { - this->current_send_packet_->callback_(packet->packet_.sent.status); + if (this->current_send_packet_->callback_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + } this->send_packet_pool_.release(this->current_send_packet_); this->current_send_packet_ = nullptr; // Reset current packet after sending } From 2778c62d07ba56f6dcb9db3e8aafd600a6b0b910 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 29 Jun 2026 11:33:56 -0400 Subject: [PATCH 208/343] [audio] Bump microMP3 to v0.4.0 (#17279) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 091f496e333..d87f32fc362 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.3.0") + add_idf_component(name="esphome/micro-mp3", ref="0.4.0") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MICRO_MP3_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 81c16f2e38b..4f36e4dbe63 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.3.0 + version: 0.4.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From b8690c8e31600201439a6ffb8325c436cde8a46e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:32:28 -0400 Subject: [PATCH 209/343] [core] Drop Python 3.11 support (#17280) --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 4 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 2 +- AGENTS.md | 2 +- esphome/async_thread.py | 9 +- esphome/components/nrf52/framework.py | 17 +-- esphome/framework_helpers.py | 124 ++++-------------- esphome/helpers.py | 10 +- esphome/platformio/library.py | 7 +- pyproject.toml | 6 +- script/lint-python | 2 +- tests/integration/state_utils.py | 5 +- tests/unit_tests/test_framework_helpers.py | 10 -- 16 files changed, 58 insertions(+), 152 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 17234e811aa..4c0c330a191 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 9678831b501..d6ad28dffe4 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -65,7 +65,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -149,7 +149,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 4bef082aab0..ac0322e2fa0 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -60,7 +60,7 @@ jobs: if: steps.pr.outputs.skip != 'true' uses: ./.github/actions/restore-python with: - python-version: "3.11" + python-version: "3.12" cache-key: ${{ hashFiles('.cache-key') }} - name: Download memory analysis artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72519e421ab..751241f563e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ permissions: contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: - DEFAULT_PYTHON: "3.11" - PYUPGRADE_TARGET: "--py311-plus" + DEFAULT_PYTHON: "3.12" + PYUPGRADE_TARGET: "--py312-plus" concurrency: # yamllint disable-line rule:line-length @@ -203,7 +203,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.11" + - "3.12" - "3.13" - "3.14" os: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b23b561bd6..20a77b152d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba74aff07cf..da424f516f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py311-plus] + args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: diff --git a/AGENTS.md b/AGENTS.md index 21905ea356f..46caea3aecb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro ## 2. Core Technologies & Stack -* **Languages:** Python (>=3.11), C++ (gnu++20) +* **Languages:** Python (>=3.12), C++ (gnu++20) * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. diff --git a/esphome/async_thread.py b/esphome/async_thread.py index c5225a7a141..3972d735f55 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -12,12 +12,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import threading -from typing import Generic, TypeVar - -_T = TypeVar("_T") -class AsyncThreadRunner(threading.Thread, Generic[_T]): +class AsyncThreadRunner[T](threading.Thread): """Run an async coroutine in a daemon thread and expose its result. The runner catches all exceptions from the coroutine and stores them in @@ -35,10 +32,10 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): result = runner.result """ - def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: super().__init__(daemon=True) self._coro_factory = coro_factory - self.result: _T | None = None + self.result: T | None = None self.exception: BaseException | None = None self.event = threading.Event() diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 05feadb0013..7aec6b088ec 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -54,22 +54,15 @@ def _get_toolchain_path(version: str) -> Path: return _get_tools_path() / "toolchains" / version -# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. _SITECUSTOMIZE = """\ -import os, stat, shutil, sys +import os, stat, shutil _orig = shutil.rmtree def _handler(func, path, exc): os.chmod(path, stat.S_IWRITE); func(path) -if sys.version_info >= (3, 12): - def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): - if onerror is None and onexc is None: - onexc = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) -else: - def _rmtree(path, ignore_errors=False, onerror=None): - if onerror is None: - onerror = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) shutil.rmtree = _rmtree """ diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index a8e5cf75a85..69cecc58e20 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -239,22 +239,19 @@ def _tar_extract_all( """ Extract a TAR archive to the specified directory. - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. + Path-traversal, link, permission and ownership sanitization is delegated to + the stdlib ``tarfile.data_filter`` (PEP 706). We keep the wrapper-directory + stripping (no stdlib equivalent) and the absolute-path reject (data_filter's + check is os.path-dependent and would miss a Windows drive path when + extracting on POSIX). Args: data: File-like object containing the TAR archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header """ - import stat import tarfile - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. extract_dir = os.fspath(extract_dir) abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 @@ -269,18 +266,14 @@ def _tar_extract_all( safe_members = [] for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) + # Strip leading slashes, then reject absolute / Windows-drive paths + name = member.name.lstrip("/" + os.sep) if Path(name).is_absolute() or ( os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue - # 3. Strip wrapper directory if one was detected + # Strip wrapper directory if one was detected if strip_prefix is not None: norm = name.replace("\\", "/") if norm in (strip_root, strip_prefix): @@ -288,88 +281,29 @@ def _tar_extract_all( if not norm.startswith(strip_prefix): continue name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - if member.islnk() and strip_prefix is not None: - # Hard-link linknames reference another archive member - # by its archive name. We've stripped the wrapper prefix - # from member.name above (step 3); strip it here too so - # tarfile._find_link_target can resolve the target during - # extraction. Symlink linknames are filesystem-relative - # paths, not archive-member references, so they don't - # need this treatment. - norm_link = linkname.replace("\\", "/") - if norm_link in (strip_root, strip_prefix): - continue - if not norm_link.startswith(strip_prefix): - continue - linkname = norm_link[len(strip_prefix) :] - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back member.name = name + # Hard-link linknames reference another archive member by its + # archive name; strip the wrapper prefix here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative paths, + # not archive-member references, so they don't need this. + if member.islnk() and strip_prefix is not None: + norm_link = member.linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + member.linkname = norm_link[len(strip_prefix) :] + + # Delegate traversal, link, permission and ownership sanitization + # to the stdlib data filter; it raises FilterError for unsafe + # members (path traversal, links outside dest, special files). + try: + member = tarfile.data_filter(member, abs_dest) + except tarfile.FilterError: + continue + safe_members.append(member) total = len(safe_members) diff --git a/esphome/helpers.py b/esphome/helpers.py index 62dfd0fb098..631bcb6f398 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -397,17 +397,13 @@ def rmtree(path: Path | str) -> None: read-only flag and retrying. """ - def _onerror(func, path, exc_info): + def _onexc(func, path, exc): if os.access(path, os.W_OK): - raise exc_info[1].with_traceback(exc_info[2]) + raise exc Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) - # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different - # callable signature); keep the existing handler shape for now and - # silence the lint locally so this PR doesn't bundle an unrelated - # migration. - shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument + shutil.rmtree(path, onexc=_onexc) def walk_files(path: Path): diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 43282c7aa02..c2d783ecbe9 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -24,7 +24,7 @@ import os from pathlib import Path import re import tempfile -from typing import Any, TypeVar +from typing import Any from urllib.parse import urlparse, urlsplit, urlunsplit from esphome import git @@ -195,10 +195,7 @@ class LibraryBackend: emit: Callable[["ConvertedLibrary"], None] -T = TypeVar("T") - - -def ensure_list(obj: T | list[T]) -> list[T]: +def ensure_list[T](obj: T | list[T]) -> list[T]: """ Convert an object to a list if it isn't already a list. diff --git a/pyproject.toml b/pyproject.toml index a2923778356..e9595785539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Home Automation", ] -requires-python = ">=3.11.0,<3.15" +requires-python = ">=3.12.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -62,7 +62,7 @@ addopts = [ ] [tool.pylint.MAIN] -py-version = "3.11" +py-version = "3.12" ignore = [ "api_pb2.py", ] @@ -106,7 +106,7 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" -target-version = "py311" +target-version = "py312" exclude = ['generated'] [tool.ruff.lint] diff --git a/script/lint-python b/script/lint-python index e4b3314d2af..6bd95778fa1 100755 --- a/script/lint-python +++ b/script/lint-python @@ -139,7 +139,7 @@ def main(): print() print("Running pyupgrade...") print() - PYUPGRADE_TARGET = "--py311-plus" + PYUPGRADE_TARGET = "--py312-plus" for files in filesets: cmd = ["pyupgrade", PYUPGRADE_TARGET] + files log = get_err(*cmd) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index c8517aff092..65af57b9447 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -19,7 +19,6 @@ from aioesphomeapi import ( _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=EntityInfo) S = TypeVar("S", bound=EntityState) @@ -58,7 +57,7 @@ async def wait_for_state( return await asyncio.wait_for(future, timeout=timeout) -def find_entity( +def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, @@ -86,7 +85,7 @@ def find_entity( return None -def require_entity( +def require_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index fd807ed05d2..6fe62dcc8cb 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -658,11 +658,6 @@ def test_get_python_env_executable_path_nt() -> None: class TestTarExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" info = tarfile.TarInfo(name="C:/secret.txt") @@ -755,11 +750,6 @@ class TestTarExtractAllBranches: class TestZipExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" buf = _make_zip([("C:/secret.txt", "bad")]) From 136e343988cd12311d1b04e5a18d0bb0e7b82f00 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:13:11 -0400 Subject: [PATCH 210/343] [ethernet] Generic and YT8531 PHY over RGMII (gigabit) for ESP32-S31 (#17277) --- esphome/components/ethernet/__init__.py | 55 ++++++++ .../components/ethernet/ethernet_component.h | 7 ++ .../ethernet/ethernet_component_esp32.cpp | 117 +++++++++++++++++- esphome/core/defines.h | 2 + 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 6af68e4e3c4..8f927cf3e93 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -126,6 +126,8 @@ ETHERNET_TYPES = { "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, "W6100": EthernetType.ETHERNET_TYPE_W6100, "W6300": EthernetType.ETHERNET_TYPE_W6300, + "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, + "YT8531": EthernetType.ETHERNET_TYPE_YT8531, } # PHY types that need compile-time defines for conditional compilation @@ -145,6 +147,8 @@ _PHY_TYPE_TO_DEFINE = { "ENC28J60": "USE_ETHERNET_ENC28J60", "W6100": "USE_ETHERNET_W6100", "W6300": "USE_ETHERNET_W6300", + "GENERIC": "USE_ETHERNET_GENERIC", + "YT8531": "USE_ETHERNET_YT8531", } @@ -309,6 +313,24 @@ def _validate(config): f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." ) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + from esphome.components.esp32 import ( + VARIANT_ESP32S31, + get_esp32_variant, + idf_version, + ) + + eth_type = config[CONF_TYPE] + variant = get_esp32_variant() + if variant != VARIANT_ESP32S31: + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable " + f"variants (ESP32-S31), not {variant}" + ) + if idf_version() < cv.Version(6, 0, 0): + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer." + ) elif config[CONF_TYPE] != "OPENETH": from esphome.components.esp32 import ( VARIANT_ESP32, @@ -392,6 +414,23 @@ RMII_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) +# Generic IEEE 802.3 PHY over the internal EMAC RGMII interface (e.g. ESP32-S31). +# RGMII data pins come from the IDF per-target default config. +GENERIC_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), + } + ) + ), + cv.only_on([Platform.ESP32]), +) + SPI_SCHEMA = cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -442,6 +481,8 @@ CONFIG_SCHEMA = cv.All( "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), "LAN8670": RMII_SCHEMA, + "GENERIC": GENERIC_SCHEMA, + "YT8531": GENERIC_SCHEMA, }, upper=True, ), @@ -571,6 +612,20 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: elif config[CONF_TYPE] == "OPENETH": cg.add_define("USE_ETHERNET_OPENETH") add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. + cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) + cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) + cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + if CONF_POWER_PIN in config: + cg.add(var.set_power_pin(config[CONF_POWER_PIN])) + for register_value in config.get(CONF_PHY_REGISTERS, []): + reg = phy_register( + register_value.get(CONF_ADDRESS), + register_value.get(CONF_VALUE), + register_value.get(CONF_PAGE_ID), + ) + cg.add(var.add_phy_register(reg)) else: cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7d06377f904..e0fe920ea16 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -86,6 +86,8 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_ENC28J60, ETHERNET_TYPE_W6100, ETHERNET_TYPE_W6300, + ETHERNET_TYPE_GENERIC, + ETHERNET_TYPE_YT8531, }; struct ManualIP { @@ -229,6 +231,11 @@ class EthernetComponent final : public Component { #ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif +#ifdef USE_ETHERNET_YT8531 + /// @brief Apply YT8531-specific config: re-enable auto-negotiation (disabled on + /// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling. + void yt8531_phy_init_(); #endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 544ec79c327..7a1bcae42fc 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -254,9 +254,14 @@ void EthernetComponent::ethernet_lazy_init_() { esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; #endif - esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = - static_cast(this->clk_pin_); + // The RGMII types (GENERIC, YT8531) use the RGMII interface and default GPIO map from + // eth_esp32_emac_default_config(); writing the RMII clock config would clobber that + // union, so skip the RMII clock override for them. + if (this->type_ != ETHERNET_TYPE_GENERIC && this->type_ != ETHERNET_TYPE_YT8531) { + esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; + esp32_emac_config.clock_config.rmii.clock_gpio = + static_cast(this->clk_pin_); + } esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); #endif @@ -319,6 +324,20 @@ void EthernetComponent::ethernet_lazy_init_() { break; } #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // GENERIC and YT8531 both use the built-in generic 802.3 PHY driver; YT8531 gets + // extra chip-specific tuning applied later in ethernet_lazy_init_(). +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: +#endif +#if defined(USE_ETHERNET_GENERIC) || defined(USE_ETHERNET_YT8531) + this->phy_ = esp_eth_phy_new_generic(&phy_config); + break; +#endif +#endif #endif #ifdef USE_ETHERNET_SPI #if defined(USE_ETHERNET_W5500) @@ -363,7 +382,30 @@ void EthernetComponent::ethernet_lazy_init_() { for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef USE_ETHERNET_GENERIC + // The generic 802.3 PHY driver only resets the PHY in its init; it never enables + // auto-negotiation. A PHY that resets into a forced-speed mode (BMCR auto-nego bit + // clear) therefore stays there, and esp_eth_start() skips negotiation because the + // driver cached auto_nego_en=false at install time. Force auto-negotiation on here + // (which also updates that cached state) so esp_eth_start() restarts a proper + // negotiation. (YT8531 does this as part of its own chip-specific init below.) + if (this->type_ == ETHERNET_TYPE_GENERIC) { + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "Enable auto-negotiation failed"); + } #endif +#ifdef USE_ETHERNET_YT8531 + if (this->type_ == ETHERNET_TYPE_YT8531) { + this->yt8531_phy_init_(); + if (this->is_failed()) + return; + } +#endif +#endif // ESP_IDF_VERSION >= 6.0.0 +#endif // !USE_ETHERNET_SPI // use ESP internal eth mac uint8_t mac_addr[6]; @@ -486,6 +528,16 @@ void EthernetComponent::dump_config() { eth_type = "LAN8670"; break; #endif +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: + eth_type = "Generic (RGMII)"; + break; +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: + eth_type = "YT8531 (RGMII)"; + break; +#endif default: eth_type = "Unknown"; @@ -782,6 +834,19 @@ void EthernetComponent::dump_connect_params_() { char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint16_t link_speed = 10; + switch (this->get_link_speed()) { + case ETH_SPEED_100M: + link_speed = 100; + break; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case ETH_SPEED_1000M: + link_speed = 1000; + break; +#endif + default: + break; + } ESP_LOGCONFIG(TAG, " IP Address: %s\n" " Hostname: '%s'\n" @@ -796,7 +861,7 @@ void EthernetComponent::dump_connect_params_() { network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), this->get_eth_mac_address_pretty_into_buffer(mac_buf), - YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); + YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), link_speed); #if USE_NETWORK_IPV6 struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; @@ -958,6 +1023,50 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi #endif } +#ifdef USE_ETHERNET_YT8531 +void EthernetComponent::yt8531_phy_init_() { + esp_err_t err; + + // The YT8531 disables auto-negotiation on hardware reset (undocumented behavior), and the + // generic 802.3 driver only resets the PHY, so re-enable it (this also updates the driver's + // cached auto-nego state used by esp_eth_start()). + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "YT8531 enable auto-negotiation failed"); + + // RGMII needs ~2 ns Tx and Rx clock delays for reliable data sampling. These are set through + // the YT8531 extended-register interface: write the ext-register address to 0x1E, then + // read/modify/write its value via 0x1F. + esp_eth_phy_reg_rw_data_t phy_reg; + uint32_t reg_val; + phy_reg.reg_value_p = ®_val; + + // RX ~2 ns coarse delay: EXT_CHIP_CONFIG (0xA001), set rxc_dly_en (bit 8). + reg_val = 0xA001; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select Chip_Config failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read Chip_Config failed"); + reg_val |= (1U << 8); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write Chip_Config failed"); + + // TX ~2 ns delay: EXT_RGMII_CONFIG1 (0xA003), tx_delay_sel[3:0] and tx_delay_sel_fe[7:4] = 13. + reg_val = 0xA003; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select RGMII_Config1 failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read RGMII_Config1 failed"); + reg_val = (reg_val & ~0x00FFU) | (13U << 4) | (13U << 0); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write RGMII_Config1 failed"); +} +#endif + #endif } // namespace esphome::ethernet diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 17b5e648622..1c0138f9d11 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -327,6 +327,8 @@ #define USE_ETHERNET_JL1101 #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_LAN8670 +#define USE_ETHERNET_GENERIC +#define USE_ETHERNET_YT8531 #define USE_ETHERNET_SPI #define USE_ETHERNET_SPI_POLLING_SUPPORT #define USE_ETHERNET_OPENETH From 8780c7e0ac26251d213ccf7232ab95b317f8f6c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:01:41 -0400 Subject: [PATCH 211/343] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.2 (#17286) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 751241f563e..73c93bc336d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 + uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2 with: packages: libsdl2-dev ccache version: 1.1 From 797ed237655ec03165789d068ea630c7095ee617 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:02:11 -0400 Subject: [PATCH 212/343] Bump tzlocal from 5.4.3 to 5.4.4 (#17283) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 956f3633dcd..9f485db38e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 -tzlocal==5.4.3 # from time +tzlocal==5.4.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 From 405607e9d29d408bc35e766450df679130c110c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:06:36 -0400 Subject: [PATCH 213/343] Bump esptool from 5.3.0 to 5.3.1 (#17284) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f485db38e5..d39d52caf47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.3.0 +esptool==5.3.1 click==8.3.3 aioesphomeapi==45.5.2 zeroconf==0.150.0 From e308075e3fb027db56c39cdb61f2ab9499ce5120 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:12:23 -0400 Subject: [PATCH 214/343] Bump puremagic from 1.30 to 2.2.0 (#17285) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d39d52caf47..85f4b56c079 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ esptool==5.3.1 click==8.3.3 aioesphomeapi==45.5.2 zeroconf==0.150.0 -puremagic==1.30 +puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 From 93eb6f78e0e651b5ff8ab54cdc86ebb043deb2a6 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:32:57 +0100 Subject: [PATCH 215/343] [network] Enlarge Zephyr net buffer pool and TCP windows on nRF52/Zephyr plataform (#17278) --- esphome/components/network/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 846c3afc599..d2683e4bba8 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -227,6 +227,21 @@ async def to_code(config): # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the # RFC 6528 keyed hash). zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) + # Enlarge the Zephyr network buffer pool and TCP windows for the Thread path. + # Zephyr's defaults are tiny: NET_BUF_TX_COUNT=16 * NET_BUF_DATA_SIZE=128 is only + # ~2 KB of TX data -- barely one 1280-byte IPv6 packet once 6LoWPAN fragments it. + # The ESPHome API entity-sync burst overruns that instantly, so socket writes fail + # with ENOBUFS ("Buffer full") and the connection is dropped. ESP32 sidesteps this + # by enlarging the lwIP TCP window (CONFIG_LWIP_TCP_* above); give Zephyr the + # equivalent headroom, sized to RAM and the Thread 1280-byte MTU (not ESP32's 64 KB). + # The bounded send window also provides flow control so TCP stops queueing past + # what the buffer pool can hold instead of erroring. + zephyr_add_prj_conf("NET_PKT_RX_COUNT", 24) + zephyr_add_prj_conf("NET_PKT_TX_COUNT", 24) + zephyr_add_prj_conf("NET_BUF_RX_COUNT", 48) + zephyr_add_prj_conf("NET_BUF_TX_COUNT", 48) + zephyr_add_prj_conf("NET_TCP_MAX_RECV_WINDOW_SIZE", 2280) + zephyr_add_prj_conf("NET_TCP_MAX_SEND_WINDOW_SIZE", 2280) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) From b36e20d60b20777daed1fd7a256e0d1027dd01a3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:25:16 -0400 Subject: [PATCH 216/343] Revert "Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.2 (#17286)" (#17289) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c93bc336d..4ac55aa0066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@5513791f75b039e2a79653b1a92238d3fb8d99b4 # v1.6.2 + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 with: packages: libsdl2-dev ccache version: 1.1 From 1611345c5520818b09738c6a1f900bb5a2867054 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:37:17 +1000 Subject: [PATCH 217/343] [agents] Add English language AI guidelines for documentation (#17290) --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 46caea3aecb..9a01626ee42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,3 +709,9 @@ This document provides essential context for AI models interacting with this pro _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate ``` +## 9. English Language + +The project uses English for non-code content. When drafting documentation, code comments, commit messages, +PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, +using standard technical terms only when required. Ensure the text is readily comprehensible to a wide +audience, including non-native English speakers. From 5c7245dfcd5766cb737f28878ab2a7c857cc2ecd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:04:25 +1000 Subject: [PATCH 218/343] [qmi8658] Motion platform for QMI8658 IMU (#16889) --- CODEOWNERS | 1 + esphome/components/qmi8658/__init__.py | 13 ++ esphome/components/qmi8658/motion.py | 93 ++++++++++++ esphome/components/qmi8658/qmi8658.cpp | 136 ++++++++++++++++++ esphome/components/qmi8658/qmi8658.h | 112 +++++++++++++++ esphome/components/qmi8658/sensor.py | 39 +++++ tests/components/qmi8658/common.yaml | 69 +++++++++ tests/components/qmi8658/test.esp32-idf.yaml | 4 + .../components/qmi8658/test.esp8266-ard.yaml | 4 + 9 files changed, 471 insertions(+) create mode 100644 esphome/components/qmi8658/__init__.py create mode 100644 esphome/components/qmi8658/motion.py create mode 100644 esphome/components/qmi8658/qmi8658.cpp create mode 100644 esphome/components/qmi8658/qmi8658.h create mode 100644 esphome/components/qmi8658/sensor.py create mode 100644 tests/components/qmi8658/common.yaml create mode 100644 tests/components/qmi8658/test.esp32-idf.yaml create mode 100644 tests/components/qmi8658/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 21121ff4762..8fc7d4a0a71 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -405,6 +405,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje esphome/components/qspi_dbi/* @clydebarrow diff --git a/esphome/components/qmi8658/__init__.py b/esphome/components/qmi8658/__init__.py new file mode 100644 index 00000000000..67838dbc3c1 --- /dev/null +++ b/esphome/components/qmi8658/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c", "motion"] + +CONF_QMI8658_ID = "qmi8658_id" +# C++ namespace / class +qmi8658_ns = cg.esphome_ns.namespace("qmi8658") +QMI8658Component = qmi8658_ns.class_("QMI8658Component", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py new file mode 100644 index 00000000000..26169189c29 --- /dev/null +++ b/esphome/components/qmi8658/motion.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import QMI8658Component, qmi8658_ns + +# Enum proxies (must match the C++ enum values exactly) +QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, + "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, + "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, + "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, +} + +QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") +GYRO_RANGE_OPTIONS = { + "16DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_16, + "32DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_32, + "64DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_64, + "128DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_128, + "256DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_256, + "512DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_512, + "1024DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_1024, + "2048DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_2048, +} + +QMI8658AccelODR = qmi8658_ns.enum("QMI8658AccelODR") +ACCEL_ODR_OPTIONS = { + "31_25HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_31_25, + "62_5HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_62_5, + "125HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_125, + "250HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_250, + "500HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_500, + "1000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_1000, + "2000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_2000, + "4000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_4000, + "8000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_8000, +} + +QMI8658GyroODR = qmi8658_ns.enum("QMI8658GyroODR") +GYRO_ODR_OPTIONS = { + "31_25HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_31_25, + "62_5HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_62_5, + "125HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_125, + "250HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_250, + "500HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_500, + "1000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_1000, + "2000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_2000, + "4000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_4000, + "8000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_8000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(QMI8658Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="1000HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2048DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="1000HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6B)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp new file mode 100644 index 00000000000..2fd457d2900 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -0,0 +1,136 @@ +#include "qmi8658.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::qmi8658 { + +static const char *const TAG = "qmi8658"; + +// Acceleration scale (g per LSB), indexed by accel_range_ >> 4. +// Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). +static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, +}; + +// Angular rate scale (°/s per LSB), indexed by gyro_range_ >> 4. +static constexpr float GYRO_SCALE[] = { + 16.0f / 32768.0f, 32.0f / 32768.0f, 64.0f / 32768.0f, 128.0f / 32768.0f, + 256.0f / 32768.0f, 512.0f / 32768.0f, 1024.0f / 32768.0f, 2048.0f / 32768.0f, +}; + +void QMI8658Component::setup() { + MotionComponent::setup(); + + // 1. Verify chip ID + uint8_t who_am_i = 0; + if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != QMI8658_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // 2. Soft reset + if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { + this->mark_failed(); + return; + } + delay(15); // spec: wait for reset to complete + + // 3. Serial interface: enable register address auto-increment + if (!this->write_byte(QMI8658_REG_CTRL1, QMI8658_CTRL1_VALUE)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL1")); + return; + } + + // 4. Configure accelerometer (CTRL2 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL2, (uint8_t) (this->accel_range_) | (uint8_t) (this->accel_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL2")); + return; + } + + // 5. Configure gyroscope (CTRL3 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL3, (uint8_t) (this->gyro_range_) | (uint8_t) (this->gyro_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL3")); + return; + } + + // 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline) + if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL5")); + this->mark_failed(); + return; + } + + // 7. Enable accelerometer and gyroscope + if (!this->write_byte(QMI8658_REG_CTRL7, QMI8658_CTRL7_ACC_EN | QMI8658_CTRL7_GYR_EN)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL7")); + return; + } + + ESP_LOGCONFIG(TAG, "QMI8658 initialised successfully"); +} + +void QMI8658Component::dump_config() { + ESP_LOGCONFIG(TAG, "QMI8658 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", + "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + MotionComponent::dump_config(); +} + +bool QMI8658Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Read temperature + accel + gyro in one contiguous block starting at TEMP_L. + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(QMI8658_REG_TEMP_L, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + + // Data is little-endian (low byte first). + float scale = ACCEL_SCALE[this->accel_range_ >> 4]; + int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); + int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + scale = GYRO_SCALE[this->gyro_range_ >> 4]; + raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); + raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); + raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); + data.angular_rate[motion::X_AXIS] = raw_x * scale; + data.angular_rate[motion::Y_AXIS] = raw_y * scale; + data.angular_rate[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: signed 16-bit, °C = raw / 256 + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + this->temperature_callback_.call(raw_t / 256.0f); + return true; +} + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h new file mode 100644 index 00000000000..ce31a2b7a9f --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::qmi8658 { + +// Register map +static constexpr uint8_t QMI8658_REG_WHO_AM_I = 0x00; +static constexpr uint8_t QMI8658_REG_REVISION = 0x01; +static constexpr uint8_t QMI8658_REG_CTRL1 = 0x02; // serial interface / auto-increment +static constexpr uint8_t QMI8658_REG_CTRL2 = 0x03; // accelerometer ODR / range +static constexpr uint8_t QMI8658_REG_CTRL3 = 0x04; // gyroscope ODR / range +static constexpr uint8_t QMI8658_REG_CTRL5 = 0x06; // low-pass filter +static constexpr uint8_t QMI8658_REG_CTRL7 = 0x08; // sensor enable +static constexpr uint8_t QMI8658_REG_STATUS0 = 0x2E; +static constexpr uint8_t QMI8658_REG_TEMP_BASE = 0x33; // start of the data block +static constexpr uint8_t QMI8658_REG_TEMP_L = 0x33; // Low byte of temperature +static constexpr uint8_t QMI8658_REG_AX_L = 0x35; +static constexpr uint8_t QMI8658_REG_GX_L = 0x3B; +static constexpr uint8_t QMI8658_REG_RESET = 0x60; + +// One contiguous read covers temperature (2) + accel (6) + gyro (6) starting at TEMP_L. +static constexpr uint8_t REG_READ_LEN = QMI8658_REG_GX_L + 6 - QMI8658_REG_TEMP_BASE; // 0x41 - 0x33 = 14 +static constexpr uint8_t TEMP_OFFS = QMI8658_REG_TEMP_L - QMI8658_REG_TEMP_BASE; // 0 +static constexpr uint8_t ACC_OFFS = QMI8658_REG_AX_L - QMI8658_REG_TEMP_BASE; // 2 +static constexpr uint8_t GYR_OFFS = QMI8658_REG_GX_L - QMI8658_REG_TEMP_BASE; // 8 + +static constexpr uint8_t QMI8658_WHO_AM_I_VALUE = 0x05; +static constexpr uint8_t QMI8658_RESET_CMD = 0xB0; +// CTRL1: bit6 ADDR_AI (register address auto-increment); little-endian, 4-wire SPI +static constexpr uint8_t QMI8658_CTRL1_VALUE = 0x40; +// CTRL7: aEN (bit0) | gEN (bit1) +static constexpr uint8_t QMI8658_CTRL7_ACC_EN = 0x01; +static constexpr uint8_t QMI8658_CTRL7_GYR_EN = 0x02; + +// Accelerometer range options (CTRL2 bits 6:4) +enum QMI8658AccelRange : uint8_t { + QMI8658_ACCEL_RANGE_2G = 0x00, + QMI8658_ACCEL_RANGE_4G = 0x10, + QMI8658_ACCEL_RANGE_8G = 0x20, + QMI8658_ACCEL_RANGE_16G = 0x30, +}; + +// Accelerometer ODR options (CTRL2 bits 3:0) +enum QMI8658AccelODR : uint8_t { + QMI8658_ACCEL_ODR_8000 = 0x00, + QMI8658_ACCEL_ODR_4000 = 0x01, + QMI8658_ACCEL_ODR_2000 = 0x02, + QMI8658_ACCEL_ODR_1000 = 0x03, + QMI8658_ACCEL_ODR_500 = 0x04, + QMI8658_ACCEL_ODR_250 = 0x05, + QMI8658_ACCEL_ODR_125 = 0x06, + QMI8658_ACCEL_ODR_62_5 = 0x07, + QMI8658_ACCEL_ODR_31_25 = 0x08, +}; + +// Gyroscope range options (CTRL3 bits 6:4) +enum QMI8658GyroRange : uint8_t { + QMI8658_GYRO_RANGE_16 = 0x00, + QMI8658_GYRO_RANGE_32 = 0x10, + QMI8658_GYRO_RANGE_64 = 0x20, + QMI8658_GYRO_RANGE_128 = 0x30, + QMI8658_GYRO_RANGE_256 = 0x40, + QMI8658_GYRO_RANGE_512 = 0x50, + QMI8658_GYRO_RANGE_1024 = 0x60, + QMI8658_GYRO_RANGE_2048 = 0x70, +}; + +// Gyroscope ODR options (CTRL3 bits 3:0) +enum QMI8658GyroODR : uint8_t { + QMI8658_GYRO_ODR_8000 = 0x00, + QMI8658_GYRO_ODR_4000 = 0x01, + QMI8658_GYRO_ODR_2000 = 0x02, + QMI8658_GYRO_ODR_1000 = 0x03, + QMI8658_GYRO_ODR_500 = 0x04, + QMI8658_GYRO_ODR_250 = 0x05, + QMI8658_GYRO_ODR_125 = 0x06, + QMI8658_GYRO_ODR_62_5 = 0x07, + QMI8658_GYRO_ODR_31_25 = 0x08, +}; + +// Main component class +class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(QMI8658AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(QMI8658GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(QMI8658GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + + // Config + QMI8658AccelRange accel_range_{QMI8658_ACCEL_RANGE_4G}; + QMI8658AccelODR accel_odr_{QMI8658_ACCEL_ODR_1000}; + QMI8658GyroRange gyro_range_{QMI8658_GYRO_RANGE_2048}; + QMI8658GyroODR gyro_odr_{QMI8658_GYRO_ODR_1000}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/sensor.py b/esphome/components/qmi8658/sensor.py new file mode 100644 index 00000000000..80b0512361d --- /dev/null +++ b/esphome/components/qmi8658/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_QMI8658_ID, QMI8658Component + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_QMI8658_ID): cv.use_id(QMI8658Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_QMI8658_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml new file mode 100644 index 00000000000..cfb0f3e1290 --- /dev/null +++ b/tests/components/qmi8658/common.yaml @@ -0,0 +1,69 @@ +sensor: + - platform: qmi8658 + name: "QMI8658 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: qmi8658 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + accelerometer_odr: 1000HZ + + # Gyroscope full-scale range: 16DPS | 32DPS | 64DPS | 128DPS | + # 256DPS | 512DPS | 1024DPS | 2048DPS + gyroscope_range: 2048DPS + + # Gyroscope output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + gyroscope_odr: 1000HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/qmi8658/test.esp32-idf.yaml b/tests/components/qmi8658/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/qmi8658/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/qmi8658/test.esp8266-ard.yaml b/tests/components/qmi8658/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/qmi8658/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml From 3e1a6b4e11c9783139a85a27bc900b4ec649d734 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:18:01 +1000 Subject: [PATCH 219/343] [cst9220] Add CST9220 and CST9217 touchscreen support (#16888) --- CODEOWNERS | 1 + esphome/components/cst9220/__init__.py | 6 + .../cst9220/touchscreen/__init__.py | 36 +++++ .../touchscreen/cst9220_touchscreen.cpp | 141 ++++++++++++++++++ .../cst9220/touchscreen/cst9220_touchscreen.h | 50 +++++++ tests/components/cst9220/common.yaml | 16 ++ tests/components/cst9220/test.esp32-idf.yaml | 12 ++ 7 files changed, 262 insertions(+) create mode 100644 esphome/components/cst9220/__init__.py create mode 100644 esphome/components/cst9220/touchscreen/__init__.py create mode 100644 esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp create mode 100644 esphome/components/cst9220/touchscreen/cst9220_touchscreen.h create mode 100644 tests/components/cst9220/common.yaml create mode 100644 tests/components/cst9220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8fc7d4a0a71..467b1b73266 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -123,6 +123,7 @@ esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow esphome/components/cst816/* @clydebarrow +esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz esphome/components/current_based/* @djwmarcx esphome/components/dac7678/* @NickB1 diff --git a/esphome/components/cst9220/__init__.py b/esphome/components/cst9220/__init__.py new file mode 100644 index 00000000000..f97c8944efa --- /dev/null +++ b/esphome/components/cst9220/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c"] + +cst9220_ns = cg.esphome_ns.namespace("cst9220") diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py new file mode 100644 index 00000000000..6d8fc5e2f68 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -0,0 +1,36 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst9220_ns + +CST9220Touchscreen = cst9220_ns.class_( + "CST9220Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST9220Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x5A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp new file mode 100644 index 00000000000..366b1846d73 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -0,0 +1,141 @@ +#include "cst9220_touchscreen.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::cst9220 { + +void CST9220Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + // Wait for the controller to leave its bootloader before talking to it. + this->set_timeout(30, [this] { this->continue_setup_(); }); +} + +void CST9220Touchscreen::continue_setup_() { + uint8_t buffer[4]; + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Enter command mode so the configuration registers can be read. + if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to enter command mode")); + this->mark_failed(); + return; + } + delay(10); + + // The firmware check code confirms that valid firmware is loaded. + if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read check code")); + this->mark_failed(); + return; + } + uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]); + if ((checkcode & 0xFFFF0000) != 0xCACA0000) { + ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode); + this->status_set_error(LOG_STR("Invalid firmware check code")); + this->mark_failed(); + return; + } + + // Read the panel resolution unless the user supplied calibration values. + if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + } + + // Read the chip type and project id and validate the controller. + if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + this->chip_id_ = encode_uint16(buffer[3], buffer[2]); + this->project_id_ = encode_uint16(buffer[1], buffer[0]); + if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { + ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); + this->status_set_error(LOG_STR("Unknown chip ID")); + this->mark_failed(); + return; + } + + // Fall back to the display dimensions if the resolution read failed. + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = this->display_->get_native_width(); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = this->display_->get_native_height(); + + this->setup_complete_ = true; +} + +void CST9220Touchscreen::update_touches() { + if (!this->setup_complete_) + return; + uint8_t data[CST9220_DATA_LENGTH]; + // Only an actual I2C failure should skip the update; a successful read with no + // touches is a real "all fingers lifted" state that must flow through so the + // base class can generate the release event. + if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + return; + } + this->status_clear_warning(); + + // Acknowledge the report so the controller can prepare the next one. + uint8_t ack = TOUCH_ACK; + this->write_register16(REG_TOUCH_DATA, &ack, 1); + + // A valid report carries the ACK marker at offset 6; offset 0 holds the first + // point and must be neither the ACK marker nor empty. Anything else means no + // valid touch data this cycle, which we report as zero touches (not a skip). + if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK) + return; + + uint8_t num_touches = data[5] & 0x7F; + if (num_touches > CST9220_MAX_TOUCHES) + num_touches = CST9220_MAX_TOUCHES; + + for (uint8_t i = 0; i < num_touches; i++) { + // The first point starts at offset 0; subsequent points are offset by the + // two status bytes that follow it. + const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2); + uint8_t id = p[0] >> 4; + uint8_t event = p[0] & 0x0F; + if (event != TOUCH_EVENT_DOWN) + continue; + // p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs. + uint16_t x = (p[1] << 4) | (p[3] >> 4); + uint16_t y = (p[2] << 4) | (p[3] & 0x0F); + ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y); + this->add_raw_touch_position_(id, x, y); + } +} + +void CST9220Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "CST9220 Touchscreen:\n" + " Chip ID: 0x%04X\n" + " Project ID: 0x%04X\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->chip_id_, this->project_id_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, + this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::cst9220 diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h new file mode 100644 index 00000000000..17050e2429a --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::cst9220 { + +static const char *const TAG = "cst9220.touchscreen"; + +// The CST92xx family uses 16-bit (big-endian) register addresses. +static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report +static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode +static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code +static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution +static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id + +static const uint8_t TOUCH_ACK = 0xAB; +static const uint8_t TOUCH_EVENT_DOWN = 0x06; + +static const uint16_t CST9220_CHIP_ID = 0x9220; +static const uint16_t CST9217_CHIP_ID = 0x9217; + +// Maximum simultaneous touch points reported by the family. +static const uint8_t CST9220_MAX_TOUCHES = 5; +// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead. +static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5; + +class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void continue_setup_(); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + uint16_t chip_id_{}; + uint16_t project_id_{}; + bool setup_complete_{}; +}; + +} // namespace esphome::cst9220 diff --git a/tests/components/cst9220/common.yaml b/tests/components/cst9220/common.yaml new file mode 100644 index 00000000000..99e14f47aee --- /dev/null +++ b/tests/components/cst9220/common.yaml @@ -0,0 +1,16 @@ +display: + - id: cst9220_display + platform: ili9xxx + model: ili9342 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${disp_reset_pin} + invert_colors: false + +touchscreen: + - id: ts_cst9220 + i2c_id: i2c_bus + platform: cst9220 + display: cst9220_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/cst9220/test.esp32-idf.yaml b/tests/components/cst9220/test.esp32-idf.yaml new file mode 100644 index 00000000000..984f08db47b --- /dev/null +++ b/tests/components/cst9220/test.esp32-idf.yaml @@ -0,0 +1,12 @@ +substitutions: + cs_pin: GPIO4 + dc_pin: GPIO5 + disp_reset_pin: GPIO12 + interrupt_pin: GPIO15 + reset_pin: GPIO25 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml From cf9d97d5ae3c6967647723bbfd51da21de7b2328 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:20:48 +1200 Subject: [PATCH 220/343] [pixoo] Add Divoom Pixoo display component (#16974) --- CODEOWNERS | 1 + esphome/components/pixoo/__init__.py | 1 + esphome/components/pixoo/display.py | 43 ++++ esphome/components/pixoo/light/__init__.py | 24 +++ esphome/components/pixoo/light/pixoo_light.h | 26 +++ esphome/components/pixoo/pixoo.cpp | 201 +++++++++++++++++++ esphome/components/pixoo/pixoo.h | 64 ++++++ tests/components/pixoo/common.yaml | 26 +++ tests/components/pixoo/test.esp32-idf.yaml | 4 + 9 files changed, 390 insertions(+) create mode 100644 esphome/components/pixoo/__init__.py create mode 100644 esphome/components/pixoo/display.py create mode 100644 esphome/components/pixoo/light/__init__.py create mode 100644 esphome/components/pixoo/light/pixoo_light.h create mode 100644 esphome/components/pixoo/pixoo.cpp create mode 100644 esphome/components/pixoo/pixoo.h create mode 100644 tests/components/pixoo/common.yaml create mode 100644 tests/components/pixoo/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 467b1b73266..d2c92f44ce9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,7 @@ esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 +esphome/components/pixoo/* @jesserockz esphome/components/pm1006/* @habbie esphome/components/pm2005/* @andrewjswan esphome/components/pmsa003i/* @sjtrny diff --git a/esphome/components/pixoo/__init__.py b/esphome/components/pixoo/__init__.py new file mode 100644 index 00000000000..b1de57df8f1 --- /dev/null +++ b/esphome/components/pixoo/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@jesserockz"] diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py new file mode 100644 index 00000000000..764f06d603a --- /dev/null +++ b/esphome/components/pixoo/display.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import display, spi +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.types import ConfigType + +DEPENDENCIES = ["spi"] +AUTO_LOAD = ["split_buffer"] + +CONF_PIXOO_ID = "pixoo_id" + +pixoo_ns = cg.esphome_ns.namespace("pixoo") +Pixoo = pixoo_ns.class_("Pixoo", cg.PollingComponent, display.Display, spi.SPIDevice) +PixooModel = pixoo_ns.enum("PixooModel") + +# Only the 64x64 panel is hardware-verified. Smaller Pixoo panels are assumed to share the +# same protocol; add them here once confirmed. +MODELS = { + "64X64": PixooModel.PIXOO_64, +} + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(Pixoo), + cv.Optional(CONF_MODEL, default="64X64"): cv.enum(MODELS, upper=True), + } +).extend(spi.spi_device_schema(cs_pin_required=True, default_data_rate=8e6)) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "pixoo", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MODEL]) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/pixoo/light/__init__.py b/esphome/components/pixoo/light/__init__.py new file mode 100644 index 00000000000..7151cdde0b3 --- /dev/null +++ b/esphome/components/pixoo/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID +from esphome.types import ConfigType + +from ..display import CONF_PIXOO_ID, Pixoo, pixoo_ns + +PixooLight = pixoo_ns.class_("PixooLight", light.LightOutput) + +CONFIG_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(PixooLight), + cv.GenerateID(CONF_PIXOO_ID): cv.use_id(Pixoo), + # The LED board applies its own gamma, so default to no gamma correction here. + cv.Optional(CONF_GAMMA_CORRECT, default=0.0): cv.positive_float, + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_PIXOO_ID]) diff --git a/esphome/components/pixoo/light/pixoo_light.h b/esphome/components/pixoo/light/pixoo_light.h new file mode 100644 index 00000000000..67f3cd5024f --- /dev/null +++ b/esphome/components/pixoo/light/pixoo_light.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/pixoo/pixoo.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// Brightness-only light that drives the Pixoo panel's LIGHT command. +class PixooLight : public light::LightOutput, public Parented { + public: + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_panel_brightness(brightness); + } +}; + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp new file mode 100644 index 00000000000..4436b1fb174 --- /dev/null +++ b/esphome/components/pixoo/pixoo.cpp @@ -0,0 +1,201 @@ +#include "pixoo.h" + +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::pixoo { + +static const char *const TAG = "pixoo"; + +// Divoom LED-board packet protocol. +static constexpr uint8_t PACKET_HEAD = 0xAA; +static constexpr uint8_t PACKET_TAIL = 0xBB; +static constexpr uint8_t CMD_DATA = 0x00; +static constexpr uint8_t CMD_LIGHT = 0x01; +static constexpr uint8_t CMD_UNUSED = 0x21; +static constexpr uint8_t CMD_SET_RGB_IOUT = 0x22; +static constexpr size_t PACKET_HEADER_LEN = 4; // head + len(2) + cmd +static constexpr size_t PACKET_STATIC_LEN = 5; // header + tail +static constexpr uint8_t DEFAULT_IOUT = 75; // per-channel LED current / white balance default + +// Pack a `0xAA len cmd data 0xBB` packet into buf; returns the packet length. +static inline size_t build_packet(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len) { + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = cmd; + if (data != nullptr && len > 0) + std::memcpy(buf + PACKET_HEADER_LEN, data, len); + buf[PACKET_HEADER_LEN + len] = PACKET_TAIL; + return len + PACKET_STATIC_LEN; +} + +// Fill `total` bytes at buf with a single UNUSED padding packet. +static inline void pad_unused(uint8_t *buf, size_t total) { + const uint16_t len = static_cast(total - PACKET_STATIC_LEN); + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = CMD_UNUSED; + buf[total - 1] = PACKET_TAIL; +} + +float Pixoo::get_setup_priority() const { return setup_priority::PROCESSOR; } + +void Pixoo::setup() { + const uint32_t num_pixels = static_cast(this->model_) * this->model_; + this->data_size_ = num_pixels * 3; + // The frame is a DATA packet (header + RGB888 + tail) followed by a DMA-chunk-sized UNUSED + // packet, so the LED board completes its final DMA block. + this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK; + + if (!this->buffer_.init(this->data_size_)) { + this->mark_failed(LOG_STR("Failed to allocate draw buffer")); + return; + } + + // The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->frame_buffer_ = allocator.allocate(this->frame_size_); + if (this->frame_buffer_ == nullptr) { + this->buffer_.free(); + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + std::memset(this->frame_buffer_, 0, this->frame_size_); + // Pre-build the constant DATA-packet framing; only the RGB888 payload changes per frame. + this->frame_buffer_[0] = PACKET_HEAD; + this->frame_buffer_[1] = static_cast(this->data_size_ & 0xFF); + this->frame_buffer_[2] = static_cast((this->data_size_ >> 8) & 0xFF); + this->frame_buffer_[3] = CMD_DATA; + this->frame_buffer_[PACKET_HEADER_LEN + this->data_size_] = PACKET_TAIL; + pad_unused(this->frame_buffer_ + this->data_size_ + PACKET_STATIC_LEN, DMA_CHUNK); + + this->spi_setup(); + + this->buffer_.fill(0x00); + + // Set the per-channel LED current. Brightness is controlled separately via the light platform. + const uint8_t iout[3] = {DEFAULT_IOUT, DEFAULT_IOUT, DEFAULT_IOUT}; + this->send_command_(CMD_SET_RGB_IOUT, iout, 3); + + // Frames are pushed synchronously inside update(), so there is no loop() work to do and the + // component is idle between updates. Marking it done (LOOP_DONE) lets LVGL's + // update_when_display_idle option treat the panel as idle and drive frames on demand. + this->disable_loop(); +} + +void Pixoo::send_command_(uint8_t cmd, const uint8_t *data, uint16_t len) { + std::memset(this->cmd_buffer_, 0, DMA_CHUNK); + const size_t used = build_packet(this->cmd_buffer_, cmd, data, len); + if (DMA_CHUNK - used >= PACKET_STATIC_LEN) + pad_unused(this->cmd_buffer_ + used, DMA_CHUNK - used); + this->enable(); + this->write_array(this->cmd_buffer_, DMA_CHUNK); + this->disable(); +} + +void Pixoo::set_panel_brightness(float brightness) { + const uint8_t pct = static_cast(lroundf(clamp(brightness, 0.0f, 1.0f) * 100.0f)); + this->send_command_(CMD_LIGHT, &pct, 1); +} + +void Pixoo::update() { + this->do_update_(); + for (size_t i = 0; i < this->data_size_; i++) + this->frame_buffer_[PACKET_HEADER_LEN + i] = this->buffer_[i]; + this->enable(); + this->write_array(this->frame_buffer_, this->frame_size_); + this->disable(); +} + +void Pixoo::set_pixel_(uint32_t index, Color color) { + const size_t off = static_cast(index) * 3; + this->buffer_[off] = color.r; + this->buffer_[off + 1] = color.g; + this->buffer_[off + 2] = color.b; +} + +void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { + if (!this->get_clipping().inside(x, y)) + return; + const int side = static_cast(this->model_); + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = side - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = side - x - 1; + y = side - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = side - y - 1; + break; + } + if (x < 0 || x >= side || y < 0 || y >= side) + return; + this->set_pixel_(static_cast(y) * side + x, color); +} + +void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping. + // Anything else defers to the base implementation, which decodes per pixel and routes through + // draw_pixel_at() so rotation, clipping and other color formats stay correct. + // NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the + // source of truth) -- keep them in sync if the base ever changes its source layout or decoding. + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + const int side = static_cast(this->model_); + const size_t line_stride = static_cast(x_offset) + w + x_pad; + for (int y = 0; y != h; y++) { + const int dst_y = y_start + y; + if (dst_y < 0 || dst_y >= side) + continue; + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x != w; x++, source_idx++) { + const int dst_x = x_start + x; + if (dst_x < 0 || dst_x >= side) + continue; + const size_t byte_idx = source_idx * 2; + const uint16_t rgb565 = + big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8); + const uint8_t r5 = (rgb565 >> 11) & 0x1F; + const uint8_t g6 = (rgb565 >> 5) & 0x3F; + const uint8_t b5 = rgb565 & 0x1F; + this->set_pixel_(static_cast(dst_y) * side + dst_x, + Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2))); + } + } +} + +void Pixoo::fill(Color color) { + if (this->is_clipping()) { + display::Display::fill(color); + return; + } + for (size_t i = 0; i < this->data_size_; i += 3) { + this->buffer_[i] = color.r; + this->buffer_[i + 1] = color.g; + this->buffer_[i + 2] = color.b; + } +} + +void Pixoo::dump_config() { + LOG_DISPLAY("", "Divoom Pixoo", this); + ESP_LOGCONFIG(TAG, " Model: %ux%u", (unsigned) this->model_, (unsigned) this->model_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.h b/esphome/components/pixoo/pixoo.h new file mode 100644 index 00000000000..4913ef85db9 --- /dev/null +++ b/esphome/components/pixoo/pixoo.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/components/split_buffer/split_buffer.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// The Pixoo's main board (where ESPHome runs) talks to a separate LED-driver board (a GD32/AT32 +// MCU) over SPI using Divoom's packet protocol: +// 0xAA, len_lo, len_hi, cmd, , 0xBB +// The image is sent as a DATA (0x00) packet carrying width*height*3 bytes of RGB888; brightness is +// a separate LIGHT (0x01) command; the LED current is set once via SET_RGB_IOUT (0x22). Command +// packets are padded out to the LED board's 240-byte DMA chunk with an UNUSED (0x21) packet. +// The model selects the (square) panel side length. +enum PixooModel : uint8_t { + PIXOO_64 = 64, +}; + +class Pixoo : public display::Display, + public spi::SPIDevice { + public: + explicit Pixoo(PixooModel model) : model_(model) {} + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override; + + // Brightness is controlled exclusively via the light platform: send a LIGHT command to the LED + // board (brightness 0..1 -> 0..100%). + void set_panel_brightness(float brightness); + + display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + protected: + int get_width_internal() override { return static_cast(this->model_); } + int get_height_internal() override { return static_cast(this->model_); } + + void set_pixel_(uint32_t index, Color color); + void send_command_(uint8_t cmd, const uint8_t *data, uint16_t len); + + // Size of the LED board's SPI DMA chunk; the command scratch buffer is one chunk. + static constexpr size_t DMA_CHUNK = 240; + + PixooModel model_; + + size_t data_size_{0}; // RGB888 image bytes: model^2 * 3 + size_t frame_size_{0}; // full SPI frame: DATA packet + trailing UNUSED packet + + split_buffer::SplitBuffer buffer_{}; + uint8_t *frame_buffer_{nullptr}; + uint8_t cmd_buffer_[DMA_CHUNK]{}; +}; + +} // namespace esphome::pixoo diff --git a/tests/components/pixoo/common.yaml b/tests/components/pixoo/common.yaml new file mode 100644 index 00000000000..e854ce8863b --- /dev/null +++ b/tests/components/pixoo/common.yaml @@ -0,0 +1,26 @@ +display: + - platform: pixoo + id: pixoo_display + model: 64x64 + cs_pin: GPIO5 + data_rate: 10MHz + update_interval: 1s + lambda: |- + it.fill(Color(0, 0, 0)); + it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0)); + it.line(0, 0, 63, 63, Color(0, 255, 0)); + + - platform: pixoo + id: pixoo_display_pages + model: 64x64 + cs_pin: GPIO21 + rotation: 90 + pages: + - id: pixoo_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255)); + +light: + - platform: pixoo + pixoo_id: pixoo_display + name: Pixoo Brightness diff --git a/tests/components/pixoo/test.esp32-idf.yaml b/tests/components/pixoo/test.esp32-idf.yaml new file mode 100644 index 00000000000..a8e18ca5031 --- /dev/null +++ b/tests/components/pixoo/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml From 091b6a0ba0d2da50a63658f2f7f34969772c85fc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:07:06 +0000 Subject: [PATCH 221/343] Bump bundled esphome-device-builder to 1.0.22 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 10850761371..04e7998f777 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 RUN \ platformio settings set enable_telemetry No \ From 359c6a7265c23f814d442c451a3d870137dcc7c8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:34:32 -0400 Subject: [PATCH 222/343] [libretiny] Update LibreTiny to v1.13.0 (#17288) --- docker/test_configs/ln882x-arduino.yaml | 2 +- esphome/components/bk72xx/boards.py | 2178 ++++++------- esphome/components/libretiny/__init__.py | 10 +- .../libretiny/generate_components.py | 4 +- esphome/components/ln882x/boards.py | 497 ++- esphome/components/rtl87xx/boards.py | 2742 ++++++++--------- platformio.ini | 4 +- .../build_components_base.ln882x-ard.yaml | 2 +- 8 files changed, 2922 insertions(+), 2517 deletions(-) diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml index 4cff3a48837..38e96630bae 100644 --- a/docker/test_configs/ln882x-arduino.yaml +++ b/docker/test_configs/ln882x-arduino.yaml @@ -2,6 +2,6 @@ esphome: name: docker-test-ln882x-arduino ln882x: - board: generic-ln882hki + board: generic-ln882h logger: diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index f8bedce329b..6054b03f78a 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -21,38 +21,6 @@ from esphome.components.libretiny.const import ( ) BK72XX_BOARDS = { - "wb2l-m1": { - "name": "WB2L_M1 Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "xh-wb3s": { - "name": "NiceMCU XH-WB3S", - "family": FAMILY_BK7238, - }, - "cbu": { - "name": "CBU Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "t1-u": { - "name": "T1-U Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7238-tuya": { - "name": "Generic - BK7238 (Tuya T1)", - "family": FAMILY_BK7238, - }, - "t1-m": { - "name": "T1-M Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya)", - "family": FAMILY_BK7231T, - }, - "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya)", - "family": FAMILY_BK7231N, - }, "cb1s": { "name": "CB1S Wi-Fi Module", "family": FAMILY_BK7231N, @@ -61,623 +29,117 @@ BK72XX_BOARDS = { "name": "CB2L Wi-Fi Module", "family": FAMILY_BK7231N, }, - "cblc5": { - "name": "CBLC5 Wi-Fi Module", + "cb2s": { + "name": "CB2S Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "cb3l": { + "name": "CB3L Wi-Fi Module", "family": FAMILY_BK7231N, }, "cb3s": { "name": "CB3S Wi-Fi Module", "family": FAMILY_BK7231N, }, - "wb3s": { - "name": "WB3S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "lsc-lma35": { - "name": "LSC LMA35 BK7231N", + "cb3se": { + "name": "CB3SE Wi-Fi Module", "family": FAMILY_BK7231N, }, - "generic-bk7252": { - "name": "Generic - BK7252", - "family": FAMILY_BK7251, - }, - "t1-3s": { - "name": "T1-3S Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "wb2l": { - "name": "WB2L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wb1s": { - "name": "WB1S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wblc5": { - "name": "WBLC5 Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "cb2s": { - "name": "CB2S Wi-Fi Module", + "cblc5": { + "name": "CBLC5 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "cbu": { + "name": "CBU Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32": { + "name": "Generic - BK7231N", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32-tuya": { + "name": "Generic - BK7231N (Tuya)", + "family": FAMILY_BK7231N, + }, + "generic-bk7231t-qfn32-tuya": { + "name": "Generic - BK7231T (Tuya)", + "family": FAMILY_BK7231T, + }, "generic-bk7238": { "name": "Generic - BK7238", "family": FAMILY_BK7238, }, - "wa2": { - "name": "WA2 Wi-Fi Module", - "family": FAMILY_BK7231Q, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, }, - "cb3l": { - "name": "CB3L Wi-Fi Module", + "generic-bk7252": { + "name": "Generic - BK7252", + "family": FAMILY_BK7251, + }, + "lsc-lma35": { + "name": "LSC LMA35 BK7231N", "family": FAMILY_BK7231N, }, "lsc-lma35-t": { "name": "LSC LMA35 BK7231T", "family": FAMILY_BK7231T, }, - "cb3se": { - "name": "CB3SE Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "wb3l": { - "name": "WB3L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, "t1-2s": { "name": "T1-2S Wi-Fi Module", "family": FAMILY_BK7238, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "wa2": { + "name": "WA2 Wi-Fi Module", + "family": FAMILY_BK7231Q, + }, + "wb1s": { + "name": "WB1S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l": { + "name": "WB2L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l-m1": { + "name": "WB2L_M1 Wi-Fi Module", + "family": FAMILY_BK7231N, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, }, + "wb3l": { + "name": "WB3L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb3s": { + "name": "WB3S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wblc5": { + "name": "WBLC5 Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, } BK72XX_BOARD_PINS = { - "wb2l-m1": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 26, - "D4": 24, - "D5": 10, - "D6": 11, - "D7": 1, - "D8": 0, - "D9": 20, - "D10": 21, - "D11": 23, - "D12": 22, - "A0": 23, - }, - "xh-wb3s": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 7, - "D1": 23, - "D2": 14, - "D3": 26, - "D4": 24, - "D5": 6, - "D6": 9, - "D7": 0, - "D8": 1, - "D9": 8, - "D10": 10, - "D11": 11, - "D12": 16, - "D13": 20, - "D14": 21, - "D15": 22, - "D16": 15, - "D17": 17, - "A0": 28, - "A1": 26, - "A2": 24, - "A3": 1, - "A4": 10, - "A5": 20, - }, - "cbu": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 20, - "D3": 22, - "D4": 23, - "D5": 1, - "D6": 0, - "D7": 8, - "D8": 7, - "D9": 6, - "D10": 26, - "D11": 24, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 9, - "D16": 17, - "D17": 15, - "D18": 21, - "A0": 23, - }, - "t1-u": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 23, - "D3": 22, - "D4": 20, - "D5": 1, - "D6": 0, - "D7": 24, - "D8": 9, - "D9": 26, - "D10": 6, - "D11": 8, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 21, - "D16": 17, - "D17": 15, - "A0": 20, - "A1": 1, - "A2": 24, - "A3": 26, - "A4": 10, - "A5": 28, - }, - "generic-bk7238-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "t1-m": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, - "generic-bk7231t-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 23, - }, - "generic-bk7231n-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 23, - }, "cb1s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -765,22 +227,28 @@ BK72XX_BOARD_PINS = { "D7": 11, "D8": 21, }, - "cblc5": { + "cb2s": { "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P21": 21, + "P23": 23, "P24": 24, "P26": 26, "PWM0": 6, + "PWM1": 7, + "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, @@ -790,14 +258,61 @@ BK72XX_BOARD_PINS = { "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 11, + "D0": 6, + "D1": 7, + "D2": 8, + "D3": 23, "D4": 10, - "D5": 1, + "D5": 11, + "D6": 24, + "D7": 26, + "D8": 0, + "D9": 1, + "D10": 21, + "A0": 23, + }, + "cb3l": { + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P21": 21, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 9, "D6": 0, "D7": 21, + "D8": 8, + "D9": 7, + "D10": 10, + "D11": 11, + "A0": 23, }, "cb3s": { "WIRE1_SCL": 20, @@ -849,9 +364,11 @@ BK72XX_BOARD_PINS = { "D13": 20, "A0": 23, }, - "wb3s": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, + "cb3se": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -859,6 +376,9 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, "P0": 0, "P1": 1, "P6": 6, @@ -868,8 +388,10 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, "P20": 20, - "P21": 21, "P22": 22, "P23": 23, "P24": 24, @@ -885,7 +407,6 @@ BK72XX_BOARD_PINS = { "SCK": 14, "SCL1": 20, "SCL2": 0, - "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, @@ -894,19 +415,61 @@ BK72XX_BOARD_PINS = { "D2": 26, "D3": 24, "D4": 6, - "D5": 7, + "D5": 9, "D6": 0, "D7": 1, - "D8": 9, - "D9": 8, + "D8": 8, + "D9": 7, "D10": 10, "D11": 11, - "D12": 22, - "D13": 21, + "D12": 15, + "D13": 22, "D14": 20, + "D15": 17, + "D16": 16, "A0": 23, }, - "lsc-lma35": { + "cblc5": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P21": 21, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 11, + "D4": 10, + "D5": 1, + "D6": 0, + "D7": 21, + }, + "cbu": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -914,6 +477,8 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, "MOSI": 16, "P0": 0, "P1": 1, @@ -924,12 +489,16 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, "P16": 16, + "P17": 17, + "P20": 20, "P21": 21, "P22": 22, "P23": 23, "P24": 24, "P26": 26, + "P28": 28, "PWM0": 6, "PWM1": 7, "PWM2": 8, @@ -939,28 +508,405 @@ BK72XX_BOARD_PINS = { "RX1": 10, "RX2": 1, "SCK": 14, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, + "D0": 14, + "D1": 16, + "D2": 20, + "D3": 22, + "D4": 23, + "D5": 1, + "D6": 0, "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, + "D8": 7, + "D9": 6, + "D10": 26, + "D11": 24, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 9, + "D16": 17, + "D17": 15, + "D18": 21, "A0": 23, }, + "generic-bk7231n-qfn32": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7231n-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7231t-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "generic-bk7252": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1085,6 +1031,161 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "lsc-lma35": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "lsc-lma35-t": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "t1-3s": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1154,6 +1255,217 @@ BK72XX_BOARD_PINS = { "A3": 26, "A4": 10, }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "wa2": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC1": 4, + "ADC3": 23, + "P0": 0, + "P4": 4, + "P6": 6, + "P7": 7, + "P8": 8, + "P10": 10, + "P11": 11, + "P18": 18, + "P19": 19, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM4": 18, + "PWM5": 19, + "RX1": 10, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 8, + "D1": 7, + "D2": 6, + "D3": 23, + "D4": 10, + "D5": 11, + "D6": 18, + "D7": 19, + "D8": 20, + "D9": 4, + "D10": 0, + "D11": 21, + "D12": 22, + "A0": 23, + }, + "wb1s": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 11, + "D1": 10, + "D2": 26, + "D3": 24, + "D4": 0, + "D5": 8, + "D6": 7, + "D7": 1, + "D8": 9, + "D9": 6, + "D10": 23, + "A0": 23, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1205,51 +1517,7 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, - "wb1s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 11, - "D1": 10, - "D2": 26, - "D3": 24, - "D4": 0, - "D5": 8, - "D6": 7, - "D7": 1, - "D8": 9, - "D9": 6, - "D10": 23, - "A0": 23, - }, - "wblc5": { + "wb2l-m1": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1262,6 +1530,8 @@ BK72XX_BOARD_PINS = { "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P20": 20, @@ -1271,95 +1541,43 @@ BK72XX_BOARD_PINS = { "P24": 24, "P26": 26, "PWM0": 6, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 10, - "D4": 11, - "D5": 1, - "D6": 0, - "D7": 20, - "D8": 21, - "D9": 22, - "D10": 23, - "A0": 23, - }, - "cb2s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, "PWM1": 7, "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, "RX2": 1, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 6, + "D0": 8, "D1": 7, - "D2": 8, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, + "D2": 6, + "D3": 26, + "D4": 24, + "D5": 10, + "D6": 11, + "D7": 1, "D8": 0, - "D9": 1, + "D9": 20, "D10": 21, + "D11": 23, + "D12": 22, "A0": 23, }, - "generic-bk7238": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, + "wb2s": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, @@ -1368,17 +1586,12 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, "P20": 20, "P21": 21, "P22": 22, "P23": 23, "P24": 24, "P26": 26, - "P28": 28, "PWM0": 6, "PWM1": 7, "PWM2": 8, @@ -1387,65 +1600,10 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "wa2": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC1": 4, - "ADC3": 23, - "P0": 0, - "P4": 4, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P18": 18, - "P19": 19, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 18, - "PWM5": 19, - "RX1": 10, "SCL1": 20, "SCL2": 0, "SDA1": 21, + "SDA2": 1, "TX1": 11, "TX2": 0, "D0": 8, @@ -1454,176 +1612,14 @@ BK72XX_BOARD_PINS = { "D3": 23, "D4": 10, "D5": 11, - "D6": 18, - "D7": 19, + "D6": 24, + "D7": 26, "D8": 20, - "D9": 4, - "D10": 0, - "D11": 21, - "D12": 22, - "A0": 23, - }, - "cb3l": { - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 21, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "A0": 23, - }, - "lsc-lma35-t": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P16": 16, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, - "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, - "A0": 23, - }, - "cb3se": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 1, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "D12": 15, + "D9": 9, + "D10": 1, + "D11": 0, + "D12": 21, "D13": 22, - "D14": 20, - "D15": 17, - "D16": 16, "A0": 23, }, "wb3l": { @@ -1686,52 +1682,7 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, - "t1-2s": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, - "wb2s": { + "wb3s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1749,6 +1700,7 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, + "P14": 14, "P20": 20, "P21": 21, "P22": 22, @@ -1763,28 +1715,152 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, + "SCK": 14, "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, - "D8": 20, - "D9": 9, - "D10": 1, - "D11": 0, - "D12": 21, - "D13": 22, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 7, + "D6": 0, + "D7": 1, + "D8": 9, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 22, + "D13": 21, + "D14": 20, "A0": 23, }, + "wblc5": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 10, + "D4": 11, + "D5": 1, + "D6": 0, + "D7": 20, + "D8": 21, + "D9": 22, + "D10": 23, + "A0": 23, + }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, } BOARDS = BK72XX_BOARDS diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index bcc393f3fd0..079bb32aabb 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -211,14 +211,14 @@ def _notify_old_style(config): # The dev and latest branches will be at *least* this version, which is what matters. # Use GitHub releases directly to avoid PlatformIO moderation delays. ARDUINO_VERSIONS = { - "dev": (cv.Version(1, 12, 1), "https://github.com/libretiny-eu/libretiny.git"), + "dev": (cv.Version(1, 13, 0), "https://github.com/libretiny-eu/libretiny.git"), "latest": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), "recommended": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), } diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 6ca16f277f4..791a2659a94 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -359,7 +359,9 @@ if __name__ == "__main__": check_base_code(BASE_CODE_INIT) # list all boards from ltchiptool components_dir = Path(__file__).parent.parent - boards = [Board(b) for b in Board.get_list()] + # Board.get_list() returns glob (filesystem) order, which is non-deterministic + # and produces noisy diffs on regeneration; sort by board id for stable output. + boards = sorted((Board(b) for b in Board.get_list()), key=lambda b: b.name) # keep track of all supported root- and chip-families components = set() families = {} diff --git a/esphome/components/ln882x/boards.py b/esphome/components/ln882x/boards.py index df44419ed21..bcd3ffbd9ee 100644 --- a/esphome/components/ln882x/boards.py +++ b/esphome/components/ln882x/boards.py @@ -15,26 +15,38 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_LN882H LN882X_BOARDS = { - "generic-ln882hki": { - "name": "Generic - LN882HKI", + "generic-ln882h": { + "name": "Generic - LN882H", "family": FAMILY_LN882H, }, - "wb02a": { - "name": "WB02A Wi-Fi/BLE Module", - "family": FAMILY_LN882H, - }, - "wl2s": { - "name": "WL2S Wi-Fi/BLE Module", + "generic-ln882h-tuya": { + "name": "Generic - LN882H (Tuya)", "family": FAMILY_LN882H, }, "ln-02": { "name": "LN-02 Wi-Fi/BLE Module", "family": FAMILY_LN882H, }, + "ln-cb3s-v1.0": { + "name": "LN-CB3S V1.0", + "family": FAMILY_LN882H, + }, + "wb02a": { + "name": "WB02A Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2h-u": { + "name": "WL2H-U Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2s": { + "name": "WL2S Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, } LN882X_BOARD_PINS = { - "generic-ln882hki": { + "generic-ln882h": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, "WIRE0_SCL_2": 2, @@ -153,27 +165,292 @@ LN882X_BOARD_PINS = { "A6": 20, "A7": 21, }, + "generic-ln882h-tuya": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 8, + "WIRE0_SCL_9": 9, + "WIRE0_SCL_10": 10, + "WIRE0_SCL_11": 11, + "WIRE0_SCL_12": 12, + "WIRE0_SCL_13": 19, + "WIRE0_SCL_14": 20, + "WIRE0_SCL_15": 21, + "WIRE0_SCL_16": 22, + "WIRE0_SCL_17": 23, + "WIRE0_SCL_18": 24, + "WIRE0_SCL_19": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 8, + "WIRE0_SDA_9": 9, + "WIRE0_SDA_10": 10, + "WIRE0_SDA_11": 11, + "WIRE0_SDA_12": 12, + "WIRE0_SDA_13": 19, + "WIRE0_SDA_14": 20, + "WIRE0_SDA_15": 21, + "WIRE0_SDA_16": 22, + "WIRE0_SDA_17": 23, + "WIRE0_SDA_18": 24, + "WIRE0_SDA_19": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 5, + "D6": 6, + "D7": 7, + "D8": 8, + "D9": 9, + "D10": 10, + "D11": 11, + "D12": 12, + "D13": 19, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "D18": 24, + "D19": 25, + "A2": 0, + "A3": 1, + "A4": 4, + "A5": 19, + "A6": 20, + "A7": 21, + }, + "ln-02": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 9, + "WIRE0_SCL_5": 11, + "WIRE0_SCL_6": 19, + "WIRE0_SCL_7": 24, + "WIRE0_SCL_8": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 9, + "WIRE0_SDA_5": 11, + "WIRE0_SDA_6": 19, + "WIRE0_SDA_7": 24, + "WIRE0_SDA_8": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 9, + "SDA0": 9, + "TX0": 2, + "TX1": 25, + "D0": 11, + "D1": 19, + "D2": 3, + "D3": 24, + "D4": 2, + "D5": 25, + "D6": 1, + "D7": 0, + "D8": 9, + "A0": 19, + "A1": 1, + "A2": 0, + }, + "ln-cb3s-v1.0": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 11, + "WIRE0_SCL_9": 20, + "WIRE0_SCL_10": 21, + "WIRE0_SCL_11": 22, + "WIRE0_SCL_12": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 11, + "WIRE0_SDA_9": 20, + "WIRE0_SDA_10": 21, + "WIRE0_SDA_11": 22, + "WIRE0_SDA_12": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 20, + "D6": 25, + "D7": 9, + "D8": 21, + "D9": 22, + "D10": 3, + "D11": 2, + "D12": 11, + "A0": 0, + "A1": 1, + "A2": 4, + "A3": 20, + "A4": 21, + }, "wb02a": { "WIRE0_SCL_0": 1, "WIRE0_SCL_1": 2, "WIRE0_SCL_2": 3, "WIRE0_SCL_3": 4, "WIRE0_SCL_4": 5, - "WIRE0_SCL_5": 7, - "WIRE0_SCL_6": 9, - "WIRE0_SCL_7": 10, - "WIRE0_SCL_8": 24, - "WIRE0_SCL_9": 25, + "WIRE0_SCL_5": 6, + "WIRE0_SCL_6": 7, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 24, + "WIRE0_SCL_10": 25, "WIRE0_SDA_0": 1, "WIRE0_SDA_1": 2, "WIRE0_SDA_2": 3, "WIRE0_SDA_3": 4, "WIRE0_SDA_4": 5, - "WIRE0_SDA_5": 7, - "WIRE0_SDA_6": 9, - "WIRE0_SDA_7": 10, - "WIRE0_SDA_8": 24, - "WIRE0_SDA_9": 25, + "WIRE0_SDA_5": 6, + "WIRE0_SDA_6": 7, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 24, + "WIRE0_SDA_10": 25, "SERIAL0_RX": 3, "SERIAL0_TX": 2, "SERIAL1_RX": 24, @@ -190,6 +467,8 @@ LN882X_BOARD_PINS = { "PA4": 4, "PA05": 5, "PA5": 5, + "PA06": 6, + "PA6": 6, "PA07": 7, "PA7": 7, "PA09": 9, @@ -206,18 +485,128 @@ LN882X_BOARD_PINS = { "TX0": 2, "TX1": 25, "D0": 7, - "D1": 5, + "D1": 6, "D2": 3, "D3": 10, "D4": 2, "D5": 1, "D6": 4, - "D7": 9, - "D8": 24, - "D9": 25, + "D7": 5, + "D8": 9, + "D9": 24, + "D10": 25, "A0": 1, "A1": 4, }, + "wl2h-u": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 11, + "WIRE0_SCL_10": 12, + "WIRE0_SCL_11": 19, + "WIRE0_SCL_12": 20, + "WIRE0_SCL_13": 21, + "WIRE0_SCL_14": 22, + "WIRE0_SCL_15": 23, + "WIRE0_SCL_16": 24, + "WIRE0_SCL_17": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 11, + "WIRE0_SDA_10": 12, + "WIRE0_SDA_11": 19, + "WIRE0_SDA_12": 20, + "WIRE0_SDA_13": 21, + "WIRE0_SDA_14": 22, + "WIRE0_SDA_15": 23, + "WIRE0_SDA_16": 24, + "WIRE0_SDA_17": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 5, + "D1": 6, + "D2": 4, + "D3": 1, + "D4": 0, + "D5": 24, + "D6": 25, + "D7": 7, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 19, + "D12": 2, + "D13": 3, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "A0": 4, + "A1": 1, + "A2": 0, + "A3": 19, + "A4": 20, + "A5": 21, + }, "wl2s": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, @@ -298,68 +687,6 @@ LN882X_BOARD_PINS = { "A1": 19, "A2": 1, }, - "ln-02": { - "WIRE0_SCL_0": 0, - "WIRE0_SCL_1": 1, - "WIRE0_SCL_2": 2, - "WIRE0_SCL_3": 3, - "WIRE0_SCL_4": 9, - "WIRE0_SCL_5": 11, - "WIRE0_SCL_6": 19, - "WIRE0_SCL_7": 24, - "WIRE0_SCL_8": 25, - "WIRE0_SDA_0": 0, - "WIRE0_SDA_1": 1, - "WIRE0_SDA_2": 2, - "WIRE0_SDA_3": 3, - "WIRE0_SDA_4": 9, - "WIRE0_SDA_5": 11, - "WIRE0_SDA_6": 19, - "WIRE0_SDA_7": 24, - "WIRE0_SDA_8": 25, - "SERIAL0_RX": 3, - "SERIAL0_TX": 2, - "SERIAL1_RX": 24, - "SERIAL1_TX": 25, - "ADC2": 0, - "ADC3": 1, - "ADC5": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA09": 9, - "PA9": 9, - "PA11": 11, - "PB03": 19, - "PB3": 19, - "PB08": 24, - "PB8": 24, - "PB09": 25, - "PB9": 25, - "RX0": 3, - "RX1": 24, - "SCL0": 9, - "SDA0": 9, - "TX0": 2, - "TX1": 25, - "D0": 11, - "D1": 19, - "D2": 3, - "D3": 24, - "D4": 2, - "D5": 25, - "D6": 1, - "D7": 0, - "D8": 9, - "A0": 19, - "A1": 1, - "A2": 0, - }, } BOARDS = LN882X_BOARDS diff --git a/esphome/components/rtl87xx/boards.py b/esphome/components/rtl87xx/boards.py index 3a5ee853f28..23d220a91ee 100644 --- a/esphome/components/rtl87xx/boards.py +++ b/esphome/components/rtl87xx/boards.py @@ -15,40 +15,24 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_RTL8710B, FAMILY_RTL8720C RTL87XX_BOARDS = { - "wr3le": { - "name": "WR3LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr2": { - "name": "WR2 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbr3": { - "name": "WBR3 Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8710bn-2mb-468k": { - "name": "Generic - RTL8710BN (2M/468k)", - "family": FAMILY_RTL8710B, - }, - "wr1e": { - "name": "WR1E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3e": { - "name": "WR3E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3": { - "name": "WR3 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, "afw121t": { "name": "AFW121T", "family": FAMILY_RTL8710B, }, - "wr3n": { - "name": "WR3N Wi-Fi Module", + "bw12": { + "name": "BW12", + "family": FAMILY_RTL8710B, + }, + "bw15": { + "name": "BW15", + "family": FAMILY_RTL8720C, + }, + "cr3l": { + "name": "CR3L Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "generic-rtl8710bn-2mb-468k": { + "name": "Generic - RTL8710BN (2M/468k)", "family": FAMILY_RTL8710B, }, "generic-rtl8710bn-2mb-788k": { @@ -59,42 +43,6 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8710BX (4M/980k)", "family": FAMILY_RTL8710B, }, - "wr2e": { - "name": "WR2E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "t112-v1.1": { - "name": "T112_V1.1", - "family": FAMILY_RTL8710B, - }, - "wr3l": { - "name": "WR3L Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbru": { - "name": "WBRU Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "wr2le": { - "name": "WR2LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "bw15": { - "name": "BW15", - "family": FAMILY_RTL8720C, - }, - "t103-v1.0": { - "name": "T103_V1.0", - "family": FAMILY_RTL8710B, - }, - "cr3l": { - "name": "CR3L Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8720cm-4mb-1712k": { - "name": "Generic - RTL8720CM (4M/1712k)", - "family": FAMILY_RTL8720C, - }, "generic-rtl8720cf-2mb-896k": { "name": "Generic - RTL8720CF (2M/896k)", "family": FAMILY_RTL8720C, @@ -103,521 +51,81 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8720CF (2M/992k)", "family": FAMILY_RTL8720C, }, - "bw12": { - "name": "BW12", - "family": FAMILY_RTL8710B, + "generic-rtl8720cm-4mb-1712k": { + "name": "Generic - RTL8720CM (4M/1712k)", + "family": FAMILY_RTL8720C, }, "t102-v1.1": { "name": "T102_V1.1", "family": FAMILY_RTL8710B, }, - "wr2l": { - "name": "WR2L Wi-Fi Module", + "t103-v1.0": { + "name": "T103_V1.0", + "family": FAMILY_RTL8710B, + }, + "t112-v1.1": { + "name": "T112_V1.1", "family": FAMILY_RTL8710B, }, "wbr1": { "name": "WBR1 Wi-Fi Module", "family": FAMILY_RTL8720C, }, + "wbr3": { + "name": "WBR3 Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "wbru": { + "name": "WBRU Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, "wr1": { "name": "WR1 Wi-Fi Module", "family": FAMILY_RTL8710B, }, + "wr1e": { + "name": "WR1E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2": { + "name": "WR2 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2e": { + "name": "WR2E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2l": { + "name": "WR2L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2le": { + "name": "WR2LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3": { + "name": "WR3 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3e": { + "name": "WR3E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3l": { + "name": "WR3L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3le": { + "name": "WR3LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3n": { + "name": "WR3N Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, } RTL87XX_BOARD_PINS = { - "wr3le": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr2": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC2": 41, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D4": 18, - "D5": 23, - "D6": 14, - "D7": 15, - "D8": 30, - "D9": 29, - "A1": 41, - }, - "wbr3": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS1": 4, - "CTS2": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PWM5": 17, - "PWM6": 18, - "RX2": 15, - "SDA0": 16, - "TX2": 16, - "D0": 7, - "D1": 11, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 12, - "D6": 16, - "D7": 17, - "D8": 18, - "D9": 19, - "D10": 13, - "D11": 14, - "D12": 15, - "D13": 0, - "D14": 1, - }, - "generic-rtl8710bn-2mb-468k": { - "SPI0_CS": 19, - "SPI0_FCS": 6, - "SPI0_FD0": 9, - "SPI0_FD1": 7, - "SPI0_FD2": 8, - "SPI0_FD3": 11, - "SPI0_FSCK": 10, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "FCS": 6, - "FD0": 9, - "FD1": 7, - "FD2": 8, - "FD3": 11, - "FSCK": 10, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA06": 6, - "PA6": 6, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 0, - "D1": 5, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 12, - "D9": 14, - "D10": 15, - "D11": 18, - "D12": 19, - "D13": 22, - "D14": 23, - "D15": 29, - "D16": 30, - "A0": 19, - "A1": 41, - }, - "wr1e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM3": 12, - "PWM4": 29, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 23, - "D1": 18, - "D2": 14, - "D3": 15, - "D4": 30, - "D5": 12, - "D6": 5, - "D7": 29, - "D8": 19, - "D9": 22, - "A0": 19, - "A1": 41, - }, - "wr3e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr3": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, "afw121t": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -686,16 +194,33 @@ RTL87XX_BOARD_PINS = { "D9": 23, "D10": 30, }, - "wr3n": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, + "bw12": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, "SERIAL2_TX": 30, - "ADC2": 41, + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, "PA00": 0, @@ -706,32 +231,269 @@ RTL87XX_BOARD_PINS = { "PA14": 14, "PA15": 15, "PA18": 18, + "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, "PWM2": 0, "PWM3": 12, - "PWM4": 5, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, - "SDA0": 30, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 30, - "D5": 5, - "D6": 12, + "D0": 5, + "D1": 29, + "D2": 0, + "D3": 19, + "D4": 22, + "D5": 30, + "D6": 14, + "D7": 12, + "D8": 15, + "D9": 18, + "D10": 23, + "A0": 19, + }, + "bw15": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM1": 1, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX2": 15, + "SCL0": 19, + "SDA0": 3, + "TX0": 14, + "TX2": 16, + "D0": 17, + "D1": 18, + "D2": 2, + "D3": 15, + "D4": 4, + "D5": 19, + "D6": 20, + "D7": 16, + "D8": 0, + "D9": 3, + "D10": 1, + "D11": 13, + "D12": 14, + }, + "cr3l": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX": 2, + "SERIAL1_TX": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX1": 2, + "RX2": 15, + "SCL0": 19, + "SDA0": 16, + "TX0": 14, + "TX1": 3, + "TX2": 16, + "D0": 20, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 15, + "D5": 16, + "D6": 17, "D7": 18, - "D8": 23, + "D8": 19, + "D9": 13, + "D10": 14, + }, + "generic-rtl8710bn-2mb-468k": { + "SPI0_CS": 19, + "SPI0_FCS": 6, + "SPI0_FD0": 9, + "SPI0_FD1": 7, + "SPI0_FD2": 8, + "SPI0_FD3": 11, + "SPI0_FSCK": 10, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "FCS": 6, + "FD0": 9, + "FD1": 7, + "FD2": 8, + "FD3": 11, + "FSCK": 10, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 0, + "D1": 5, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 12, + "D9": 14, + "D10": 15, + "D11": 18, + "D12": 19, + "D13": 22, + "D14": 23, + "D15": 29, + "D16": 30, + "A0": 19, "A1": 41, }, "generic-rtl8710bn-2mb-788k": { @@ -930,13 +692,363 @@ RTL87XX_BOARD_PINS = { "D16": 30, "A0": 19, }, - "wr2e": { + "generic-rtl8720cf-2mb-896k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cf-2mb-992k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cm-4mb-1712k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "t102-v1.1": { "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D3": 30, + "D4": 29, + "D5": 18, + "D6": 23, + "D7": 14, + "D8": 15, + }, + "t103-v1.0": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, "WIRE0_SDA_0": 19, "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, @@ -946,8 +1058,12 @@ RTL87XX_BOARD_PINS = { "CS0": 19, "CS1": 19, "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, + "PA00": 0, + "PA0": 0, "PA05": 5, "PA5": 5, "PA12": 12, @@ -955,30 +1071,35 @@ RTL87XX_BOARD_PINS = { "PA15": 15, "PA18": 18, "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, + "PWM2": 0, "PWM3": 12, - "PWM4": 29, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 12, - "D1": 19, - "D2": 5, - "D3": 18, - "D4": 23, - "D5": 14, - "D6": 15, - "D7": 30, - "D8": 29, + "D0": 19, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 22, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, "A0": 19, "A1": 41, }, @@ -1051,76 +1172,129 @@ RTL87XX_BOARD_PINS = { "D10": 30, "A0": 19, }, - "wr3l": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, + "wbr1": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "MOSI0": 4, "PA00": 0, "PA0": 0, - "PA05": 5, - "PA5": 5, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA11": 11, "PA12": 12, + "PA13": 13, "PA14": 14, "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PWM5": 17, + "PWM6": 18, + "PWM7": 13, + "RX2": 15, + "SCL0": 15, + "SDA0": 12, + "TX2": 16, + "D0": 14, + "D1": 13, + "D2": 2, + "D3": 3, + "D4": 16, + "D5": 4, + "D6": 11, + "D7": 15, + "D8": 12, + "D9": 17, + "D10": 18, + "D11": 0, + "D12": 1, + }, + "wbr3": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS1": 4, + "CTS2": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, "PA18": 18, "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, + "PWM5": 17, + "PWM6": 18, + "RX2": 15, + "SDA0": 16, + "TX2": 16, + "D0": 7, + "D1": 11, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 12, + "D6": 16, + "D7": 17, + "D8": 18, + "D9": 19, + "D10": 13, + "D11": 14, + "D12": 15, + "D13": 0, + "D14": 1, }, "wbru": { "SPI0_CS_0": 2, @@ -1215,724 +1389,6 @@ RTL87XX_BOARD_PINS = { "D16": 10, "D17": 7, }, - "wr2le": { - "MISO0": 22, - "MISO1": 22, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA22": 22, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "SCL0": 22, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 22, - "D4": 12, - }, - "bw15": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM1": 1, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX2": 15, - "SCL0": 19, - "SDA0": 3, - "TX0": 14, - "TX2": 16, - "D0": 17, - "D1": 18, - "D2": 2, - "D3": 15, - "D4": 4, - "D5": 19, - "D6": 20, - "D7": 16, - "D8": 0, - "D9": 3, - "D10": 1, - "D11": 13, - "D12": 14, - }, - "t103-v1.0": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 19, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 22, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "cr3l": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX": 2, - "SERIAL1_TX": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX1": 2, - "RX2": 15, - "SCL0": 19, - "SDA0": 16, - "TX0": 14, - "TX1": 3, - "TX2": 16, - "D0": 20, - "D1": 2, - "D2": 3, - "D3": 4, - "D4": 15, - "D5": 16, - "D6": 17, - "D7": 18, - "D8": 19, - "D9": 13, - "D10": 14, - }, - "generic-rtl8720cm-4mb-1712k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-896k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-992k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "bw12": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 5, - "D1": 29, - "D2": 0, - "D3": 19, - "D4": 22, - "D5": 30, - "D6": 14, - "D7": 12, - "D8": 15, - "D9": 18, - "D10": 23, - "A0": 19, - }, - "t102-v1.1": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D3": 30, - "D4": 29, - "D5": 18, - "D6": 23, - "D7": 14, - "D8": 15, - }, - "wr2l": { - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA19": 19, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "SDA0": 19, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 19, - "D4": 12, - "A0": 19, - }, - "wbr1": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "MOSI0": 4, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PWM5": 17, - "PWM6": 18, - "PWM7": 13, - "RX2": 15, - "SCL0": 15, - "SDA0": 12, - "TX2": 16, - "D0": 14, - "D1": 13, - "D2": 2, - "D3": 3, - "D4": 16, - "D5": 4, - "D6": 11, - "D7": 15, - "D8": 12, - "D9": 17, - "D10": 18, - "D11": 0, - "D12": 1, - }, "wr1": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -2001,6 +1457,550 @@ RTL87XX_BOARD_PINS = { "A0": 19, "A1": 41, }, + "wr1e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 23, + "D1": 18, + "D2": 14, + "D3": 15, + "D4": 30, + "D5": 12, + "D6": 5, + "D7": 29, + "D8": 19, + "D9": 22, + "A0": 19, + "A1": 41, + }, + "wr2": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D4": 18, + "D5": 23, + "D6": 14, + "D7": 15, + "D8": 30, + "D9": 29, + "A1": 41, + }, + "wr2e": { + "WIRE0_SCL": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 19, + "D2": 5, + "D3": 18, + "D4": 23, + "D5": 14, + "D6": 15, + "D7": 30, + "D8": 29, + "A0": 19, + "A1": 41, + }, + "wr2l": { + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA19": 19, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "SDA0": 19, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 19, + "D4": 12, + "A0": 19, + }, + "wr2le": { + "MISO0": 22, + "MISO1": 22, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA22": 22, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "SCL0": 22, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 22, + "D4": 12, + }, + "wr3": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3l": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3le": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3n": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 30, + "D5": 5, + "D6": 12, + "D7": 18, + "D8": 23, + "A1": 41, + }, } BOARDS = RTL87XX_BOARDS diff --git a/platformio.ini b/platformio.ini index bca29106167..061e92a64a5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -224,7 +224,7 @@ build_unflags = ; This are common settings for the LibreTiny (all variants) using Arduino. [common:libretiny-arduino] extends = common:arduino -platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 +platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = @@ -525,7 +525,7 @@ build_unflags = [env:ln882h-arduino] extends = common:libretiny-arduino -board = generic-ln882hki +board = generic-ln882h build_flags = ${common:libretiny-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/tests/test_build_components/build_components_base.ln882x-ard.yaml b/tests/test_build_components/build_components_base.ln882x-ard.yaml index 80fc6690f91..34abcb5a77f 100644 --- a/tests/test_build_components/build_components_base.ln882x-ard.yaml +++ b/tests/test_build_components/build_components_base.ln882x-ard.yaml @@ -3,7 +3,7 @@ esphome: friendly_name: $component_name ln882x: - board: generic-ln882hki + board: generic-ln882h logger: level: VERY_VERBOSE From faa5f72500c341c6ec12d8711c32ff8455c84852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 30 Jun 2026 15:16:18 +0300 Subject: [PATCH 223/343] [mqtt] Add LN882X (LN882H) platform support (#17297) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mqtt/__init__.py | 11 ++++++++++- tests/components/mqtt/test.ln882x-ard.yaml | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/components/mqtt/test.ln882x-ard.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 86bba11a60b..4a5eacf4498 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -57,6 +57,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, PlatformFramework, ) @@ -318,7 +319,15 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), _consume_mqtt_sockets, ) diff --git a/tests/components/mqtt/test.ln882x-ard.yaml b/tests/components/mqtt/test.ln882x-ard.yaml new file mode 100644 index 00000000000..25cb37a0b42 --- /dev/null +++ b/tests/components/mqtt/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml From 9e72027b6455a90bc41498942e45ee28c962ae85 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 30 Jun 2026 05:33:33 -0700 Subject: [PATCH 224/343] [devcontainer] Align base image with production, fix Python venv and build tools (#17296) Co-authored-by: Claude Opus 4.8 --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 7 +++++-- script/setup | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 51e2232d24b..6f7e8922849 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.1 FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29f63b54b52..9181275269c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,6 @@ // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], - "appPort": 6052, // if you are using avahi in the host device, uncomment these to allow the // devcontainer to find devices via mdns //"mounts": [ @@ -41,7 +40,11 @@ ], "settings": { "python.languageServer": "Pylance", - "python.pythonPath": "/usr/bin/python3", + // Use the container's pre-provisioned venv (built by the Dockerfile, outside the + // bind-mounted workspace) rather than a ./venv that may leak in from the host and + // mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv). + "python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python", + "python.terminal.activateEnvironment": true, "pylint.args": [ "--rcfile=${workspaceFolder}/pyproject.toml" ], diff --git a/script/setup b/script/setup index 8cad7017ff3..709eaee0f35 100755 --- a/script/setup +++ b/script/setup @@ -4,7 +4,12 @@ set -e cd "$(dirname "$0")/.." -if [ ! -n "$VIRTUAL_ENV" ]; then +if [ -n "$VIRTUAL_ENV" ]; then + # A virtual environment is already active (e.g. the devcontainer's pre-provisioned + # esphome-venv). Install into it rather than creating a ./venv in the workspace. + created_venv=false +else + created_venv=true if [ -x "$(command -v uv)" ]; then uv venv --seed venv else @@ -26,4 +31,10 @@ mkdir -p .temp echo echo -echo "Virtual environment created. Run 'source venv/bin/activate' to use it." +if [ "$created_venv" = true ]; then + echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." +else + echo "Dependencies installed into the active virtual environment:" + echo " $VIRTUAL_ENV" + echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." +fi From fb5d8b5d4c07818fd75ae4e2306d96a8ba42164c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:43:01 +1000 Subject: [PATCH 225/343] [mipi_spi] Bug fixes (#17247) --- esphome/components/mipi_spi/display.py | 2 ++ esphome/components/mipi_spi/mipi_spi.h | 3 +++ esphome/components/mipi_spi/models/ili.py | 7 +------ tests/component_tests/mipi_spi/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 41624590586..871736abd17 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -425,6 +425,8 @@ async def to_code(config): dc_pin = await cg.gpio_pin_expression(dc_pin) cg.add(var.set_dc_pin(dc_pin)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( lamb, [(display.DisplayRef, "it")], return_type=cg.void diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index d9627899e04..48184fa5c1b 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -151,6 +151,9 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); + } else { + // no reset pin, send software reset command + this->write_command_(SW_RESET_CMD); } // need to know when the display is ready for SLPOUT command - will be 120ms after reset diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5df7a275dff..5598a51073f 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -24,13 +24,11 @@ from esphome.components.mipi import ( PWSET, PWSETN, SETEXTC, - SWRESET, VMCTR, VMCTR1, VMCTR2, VSCRSADD, DriverChip, - delay, ) from esphome.components.spi import TYPE_OCTAL @@ -367,7 +365,6 @@ ST7796 = DriverChip( width=320, height=480, initsequence=( - (SWRESET,), (CSCON, 0xC3), (CSCON, 0x96), (VMCTR1, 0x1C), @@ -728,8 +725,6 @@ DriverChip( width=128, height=160, initsequence=( - SWRESET, - delay(10), (FRMCTR1, 0x01, 0x2C, 0x2D), (FRMCTR2, 0x01, 0x2C, 0x2D), (FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D), @@ -786,7 +781,7 @@ ST7796.extend( bus_mode=TYPE_OCTAL, mirror_x=True, reset_pin=4, - dc_pin=0, + dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index dbd8e15702e..8edbe095b7a 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -377,6 +377,6 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp + assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp From 12b78e7c47abcae5dd518ca07c7c0439bae3232d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:37:17 -0400 Subject: [PATCH 226/343] [qmi8658] Pin i2c_id in test config to fix grouped component test conflict (#17303) --- tests/components/qmi8658/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml index cfb0f3e1290..7d4de0f97e7 100644 --- a/tests/components/qmi8658/common.yaml +++ b/tests/components/qmi8658/common.yaml @@ -49,6 +49,7 @@ sensor: motion: - platform: qmi8658 + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 43b3aa0712dd654495abb0243b95837e379ae6f7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:37:59 -0400 Subject: [PATCH 227/343] [ci] Fix nRF52 zigbee/network test-grouping conflict (#17295) --- script/helpers.py | 99 +++++++++++++++---- script/test_build_components.py | 38 +++++-- tests/components/api/test.nrf52-adafruit.yaml | 3 + .../components/mdns/test.nrf52-adafruit.yaml | 3 + .../network/test.nrf52-adafruit.yaml | 4 + .../components/network/test.nrf52-mcumgr.yaml | 4 + .../network/test.nrf52-xiao-ble.yaml | 4 + tests/script/test_helpers.py | 45 +++++++++ 8 files changed, 171 insertions(+), 29 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index fc2a3607fbd..0086a00e858 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -238,6 +238,72 @@ class _ConflictWalk: rejects: set[str] +@cache +def _get_test_config_components(component: str, platform: str) -> frozenset[str]: + """Return the components referenced by a component's test config for a platform. + + Loads ``tests/components//test..yaml`` and extracts the + top-level component keys (and list ``platform:`` values). This lets the + conflict splitter see components that are only pulled in via a test config + (e.g. nRF52 ``network`` tests that also enable ``openthread``), which a + purely static AUTO_LOAD/CONFLICTS_WITH parse cannot discover -- notably for + components like ``api`` whose ``AUTO_LOAD`` is a callable. + + Failures (missing file, parse error) are treated as empty so the splitter + never crashes on a malformed or absent test config. + """ + from esphome import yaml_util + + test_file = ( + Path(root_path) / "tests" / "components" / component / f"test.{platform}.yaml" + ) + if not test_file.exists(): + return frozenset() + try: + config = yaml_util.load_yaml(test_file) + except Exception: # noqa: BLE001 - never let a bad test config crash grouping + # Matches analyze_component_buses, which loads these same files and + # silently tolerates parse failures; surfacing it only here would be + # inconsistent and noisy. + return frozenset() + if not isinstance(config, dict): + return frozenset() + return frozenset(_extract_components_from_yaml(config)) + + +@cache +def _conflict_walk(comp: str, platform: str) -> _ConflictWalk: + """Build the platform-aware conflict walk for a single component. + + Seeds the walk with the component itself plus any components pulled in via + its ``test..yaml`` config, then folds in each seed's static + AUTO_LOAD closure and CONFLICTS_WITH declarations. Cached per + ``(component, platform)`` since the test-config seeds are platform-specific. + """ + seeds = {comp} | set(_get_test_config_components(comp, platform)) + walk = _ConflictWalk(loaded=set(seeds), rejects=set()) + stack = list(seeds) + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + return walk + + +def components_conflict(a: str, b: str, platform: str) -> bool: + """Return True if components ``a`` and ``b`` cannot share a build on ``platform``. + + Uses the same platform-aware conflict walk as :func:`split_conflicting_groups` + so callers (e.g. the no-bus redistribution in ``test_build_components.py``) + agree with how groups were originally split. The conflict relation is + symmetric even when only one side declares CONFLICTS_WITH. + """ + wa, wb = _conflict_walk(a, platform), _conflict_walk(b, platform) + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint(wa.loaded) + + def split_conflicting_groups( grouped_components: dict[tuple[str, str], list[str]], ) -> dict[tuple[str, str], list[str]]: @@ -250,33 +316,24 @@ def split_conflicting_groups( conflict relation is treated as symmetric even when only one side declares it (e.g. ethernet rejects wifi but wifi does not declare the reverse). + + The walk is platform-aware: in addition to the static AUTO_LOAD closure, + each ``(component, platform)`` walk is seeded with the components found in + that component's ``test..yaml`` config. This catches conflicts + that only exist on a given platform and are expressed through the test + config rather than static metadata -- e.g. on nRF52 the ``network``/``api`` + test configs also enable ``openthread``, which ``zigbee`` declares a + conflict with, so ``api`` and ``zigbee`` end up split there. On ESP32 those + test configs have no ``openthread``, so the components still group together. """ - batch = {c for comps in grouped_components.values() for c in comps} - - walks: dict[str, _ConflictWalk] = {} - for comp in batch: - walk = _ConflictWalk(loaded={comp}, rejects=set()) - stack = [comp] - while stack: - metadata = parse_component_metadata(stack.pop()) - walk.rejects |= metadata.conflicts_with - new = metadata.auto_load - walk.loaded - walk.loaded |= new - stack.extend(new) - walks[comp] = walk - - def conflicts(a: str, b: str) -> bool: - wa, wb = walks[a], walks[b] - return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( - wa.loaded - ) - result: dict[tuple[str, str], list[str]] = {} for (platform, signature), components in grouped_components.items(): buckets: list[list[str]] = [] for comp in components: for bucket in buckets: - if not any(conflicts(comp, other) for other in bucket): + if not any( + components_conflict(comp, other, platform) for other in bucket + ): bucket.append(comp) break else: diff --git a/script/test_build_components.py b/script/test_build_components.py index 651268609e1..ce2a35add35 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -40,6 +40,7 @@ from script.analyze_component_buses import ( uses_local_file_references, ) from script.helpers import ( + components_conflict, get_component_test_files, is_validate_only_file, parse_test_filename, @@ -788,14 +789,35 @@ def run_grouped_component_tests( if plat == platform and sig != NO_BUSES_SIGNATURE ] - if platform_groups: - # Distribute no_buses components round-robin across existing groups - for i, comp in enumerate(no_buses_comps): - sig, _ = platform_groups[i % len(platform_groups)] - grouped_components[(platform, sig)].append(comp) - else: - # No other groups for this platform - keep no_buses components together - grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps + # Distribute no_buses components round-robin across existing groups, + # but never place a component into a group it conflicts with. Conflict + # splitting (split_conflicting_groups) may have created sibling groups + # like "no_buses__conflict1" precisely to keep incompatible components + # apart (e.g. on nRF52, network pulls in openthread which zigbee + # conflicts with); redistribution must not silently undo that split. + leftover: list[str] = [] + for i, comp in enumerate(no_buses_comps): + placed = False + # Try groups starting at the round-robin offset to keep the spread. + for offset in range(len(platform_groups)): + sig, comps = platform_groups[(i + offset) % len(platform_groups)] + if any(components_conflict(comp, other, platform) for other in comps): + continue + # comps is the same list object stored in grouped_components, so + # this also extends the group in grouped_components. + comps.append(comp) + placed = True + break + if not placed: + leftover.append(comp) + + if leftover: + # Components that conflict with every existing group stay together in + # their own no_buses group (they were grouped before, so they don't + # conflict with each other). + grouped_components.setdefault((platform, NO_BUSES_SIGNATURE), []).extend( + leftover + ) groups_to_test = [] individual_tests = set() # Use set to avoid duplicates diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml index 9229d68aa3a..18bf23d7106 100644 --- a/tests/components/api/test.nrf52-adafruit.yaml +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -1,4 +1,7 @@ network: enable_ipv6: true +openthread: + tlv: 0E080000000000010000 + api: diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml index 6aff688ff49..c24d0a19087 100644 --- a/tests/components/mdns/test.nrf52-adafruit.yaml +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -1,4 +1,7 @@ network: enable_ipv6: true +openthread: + tlv: 0E080000000000010000 + mdns: diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml index 61889b0361b..ac2fe63739c 100644 --- a/tests/components/network/test.nrf52-adafruit.yaml +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml index 61889b0361b..ac2fe63739c 100644 --- a/tests/components/network/test.nrf52-mcumgr.yaml +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml index 61889b0361b..ac2fe63739c 100644 --- a/tests/components/network/test.nrf52-xiao-ble.yaml +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 82ff5e14112..886d413ccff 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -35,6 +35,8 @@ def clear_helpers_cache() -> None: helpers._get_github_event_data.cache_clear() helpers._get_changed_files_github_actions.cache_clear() helpers.get_components_per_integration_fixture.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() @pytest.mark.parametrize( @@ -1504,6 +1506,8 @@ def fake_components(tmp_path: Path) -> Path: write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") write("broken", "this is not valid python !!!") helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() return tmp_path @@ -1624,6 +1628,47 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( assert signature.startswith("i2c__conflict") +def test_split_conflicting_groups_seeds_from_test_config( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A conflict reachable only via a component's test config splits the group. + + ``host_user`` declares no static conflict with ``beta``, but its + ``test..yaml`` pulls in ``beta_variant`` (which AUTO_LOADs + ``beta``). On that platform the group must split; on another platform + (no such test config) it must stay together. + """ + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + + # host_user has no static metadata, but its esp32 test config references + # beta_variant -> AUTO_LOAD beta, which conflicts with alpha. + tests_dir = fake_components / "tests" / "components" / "host_user" + tests_dir.mkdir(parents=True) + (tests_dir / "test.esp32.yaml").write_text("beta_variant:\n") + (fake_components / "esphome" / "components" / "host_user").mkdir() + ( + fake_components / "esphome" / "components" / "host_user" / "__init__.py" + ).write_text("") + + helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() + + # On esp32, host_user pulls in beta (via its test config) -> conflicts with alpha. + result = helpers.split_conflicting_groups( + {("esp32", "no_buses"): ["alpha", "host_user"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "host_user"} <= set(bucket)) + + # On a platform without that test config, they stay grouped together. + result_other = helpers.split_conflicting_groups( + {("rp2040", "no_buses"): ["alpha", "host_user"]} + ) + assert result_other == {("rp2040", "no_buses"): ["alpha", "host_user"]} + + # --------------------------------------------------------------------------- # get_component_test_files / is_validate_only_file # --------------------------------------------------------------------------- From 3035355c0ade9c2f9d6ac885cf95582c3e19c50d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:38:31 -0400 Subject: [PATCH 228/343] [ci] Widen import-time margin for CI runner variance (#17287) --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index af3aa835113..855d89c56da 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", - "margin_pct": 15, + "margin_pct": 20, "cumulative_us": 91000 } From afb5922f3748bbade779fbee840a57cc3fd5e7ea Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 30 Jun 2026 11:26:51 -0700 Subject: [PATCH 229/343] [modbus] Update client components to use ModbusClientDevice (#11987) --- esphome/components/growatt_solar/growatt_solar.h | 2 +- esphome/components/growatt_solar/sensor.py | 12 ++++++++++-- esphome/components/havells_solar/havells_solar.h | 2 +- esphome/components/havells_solar/sensor.py | 12 ++++++++++-- esphome/components/kuntze/kuntze.h | 2 +- esphome/components/kuntze/sensor.py | 12 ++++++++++-- esphome/components/modbus/__init__.py | 8 ++++++++ esphome/components/modbus/modbus.h | 4 +++- esphome/components/modbus_controller/__init__.py | 7 ++++--- esphome/components/pzemac/pzemac.h | 2 +- esphome/components/pzemac/sensor.py | 12 ++++++++++-- esphome/components/pzemdc/pzemdc.h | 2 +- esphome/components/pzemdc/sensor.py | 12 ++++++++++-- esphome/components/sdm_meter/sdm_meter.h | 2 +- esphome/components/sdm_meter/sensor.py | 14 ++++++++++++-- esphome/components/selec_meter/selec_meter.h | 2 +- esphome/components/selec_meter/sensor.py | 12 ++++++++++-- 17 files changed, 94 insertions(+), 25 deletions(-) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 76d430737ad..18a7c917d5d 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void loop() override; void update() override; diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 7458b88b724..d1f00693413 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -47,7 +48,7 @@ CODEOWNERS = ["@leeuwte"] growatt_solar_ns = cg.esphome_ns.namespace("growatt_solar") GrowattSolar = growatt_solar_ns.class_( - "GrowattSolar", cg.PollingComponent, modbus.ModbusDevice + "GrowattSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -162,10 +163,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index ec6d5b56570..02e999c56ce 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index f0683e1d9c1..d18ae0d9af6 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -28,6 +28,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -58,7 +59,7 @@ CODEOWNERS = ["@sourabhjaiswal"] havells_solar_ns = cg.esphome_ns.namespace("havells_solar") HavellsSolar = havells_solar_ns.class_( - "HavellsSolar", cg.PollingComponent, modbus.ModbusDevice + "HavellsSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -216,10 +217,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("havells_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_FREQUENCY in config: sens = await sensor.new_sensor(config[CONF_FREQUENCY]) diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 99dd78e5b60..46681843d2a 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze final : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 96b6334730d..c11ede9db69 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -15,13 +15,14 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PH, ) +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] AUTO_LOAD = ["modbus"] kuntze_ns = cg.esphome_ns.namespace("kuntze") -Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusDevice) +Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusClientDevice) CONF_DIS1 = "dis1" CONF_DIS2 = "dis2" @@ -88,10 +89,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("kuntze", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_PH in config: conf = config[CONF_PH] diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index cf1d4093936..9e64540382f 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Literal from esphome import pins @@ -10,6 +11,8 @@ from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") @@ -129,4 +132,9 @@ async def register_modbus_server_device(var, config): async def register_modbus_device(var, config): + # Remove before 2026.12.0 + _LOGGER.warning( + "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " + "instead. Will be removed in 2026.12.0" + ) return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 4aa3a16c3a7..b0f2aed9f82 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -197,7 +197,9 @@ class ModbusClientDevice { }; // This is for compatibility with external components using the former class name -using ModbusDevice = ModbusClientDevice; +// Remove before 2026.12.0 +using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", + "2026.6.0") = ModbusClientDevice; // Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. using ServerResponseStatus = std::optional; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 67e5757397c..cdbba54c1f9 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -11,6 +11,7 @@ from esphome.components.modbus.helpers import ( import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging +from esphome.types import ConfigType from .const import ( CONF_ALLOW_DUPLICATE_COMMANDS, @@ -42,7 +43,7 @@ MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusDevice + "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice ) SensorItem = modbus_controller_ns.struct("SensorItem") @@ -117,7 +118,7 @@ def validate_modbus_register(config): return config -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_controller", role="client")( config ) @@ -211,7 +212,7 @@ async def to_code(config): async def register_modbus_device(var, config): cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) - return await modbus.register_modbus_device(var, config) + return await modbus.register_modbus_client_device(var, config) def function_code_to_register(function_code): diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index a25a8cb631a..a3ad7e11673 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC final : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index c134bc19c1a..4e228f6aa35 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,11 +26,12 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") -PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusDevice) +PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) @@ -97,10 +98,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemac", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index e398330cd36..7d14a5ed4be 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC final : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 3291be4c341..40cfe7b08af 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,11 +20,12 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") -PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusDevice) +PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) @@ -79,10 +80,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemdc", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index a4dbde016c5..aa71fcaa47e 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter final : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8006d0b4ba8..46f5025080d 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -41,12 +41,15 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@polyfaces", "@jesserockz"] sdm_meter_ns = cg.esphome_ns.namespace("sdm_meter") -SDMMeter = sdm_meter_ns.class_("SDMMeter", cg.PollingComponent, modbus.ModbusDevice) +SDMMeter = sdm_meter_ns.class_( + "SDMMeter", cg.PollingComponent, modbus.ModbusClientDevice +) PHASE_SENSORS = { CONF_VOLTAGE: sensor.sensor_schema( @@ -145,10 +148,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_TOTAL_POWER in config: sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 6b5552a0981..c367d1d15d1 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter final : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 1a53eb5c373..ef4929c3751 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -32,6 +32,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] @@ -49,7 +50,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( - "SelecMeter", cg.PollingComponent, modbus.ModbusDevice + "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) SENSORS = { @@ -163,10 +164,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("selec_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) for name in SENSORS: if name in config: sens = await sensor.new_sensor(config[name]) From b79cbcbde77dce3582c5d4ae9dd0045b08a596e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:18 -0400 Subject: [PATCH 230/343] [espidf] Install native ESP-IDF into a machine-global cache dir (#17306) --- docker/docker_entrypoint.sh | 4 ++ .../etc/s6-overlay/s6-rc.d/esphome/run | 4 ++ esphome/__main__.py | 5 +- esphome/espidf/clang_tidy.py | 6 +-- esphome/espidf/framework.py | 35 +++++++++----- esphome/writer.py | 9 ++++ requirements.txt | 1 + tests/unit_tests/test_espidf_framework.py | 47 +++++++++++++++++++ tests/unit_tests/test_writer.py | 38 +++++++++++++-- 9 files changed, 129 insertions(+), 20 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 18baf40c29b..598b553c082 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,6 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native ESP-IDF install on the persistent cache root, not the +# container's ephemeral user cache dir (re-downloaded on every restart). +export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" + # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) if [[ -d /build ]]; then diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index dff61fd2f3c..f50de659b90 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,6 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native ESP-IDF install on the persistent /data volume, not the +# container's ephemeral user cache dir (wiped on every add-on update/restart). +export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf + if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi diff --git a/esphome/__main__.py b/esphome/__main__.py index 1062df7167b..1767d3b7cac 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2386,7 +2386,10 @@ def parse_args(argv): ) parser_clean_all = subparsers.add_parser( - "clean-all", help="Clean all build and platform files." + "clean-all", + help="Clean all build and platform files, including machine-global " + "toolchain caches shared by all configurations, so other projects will " + "re-download them on next build.", ) parser_clean_all.add_argument( "configuration", help="Your YAML file or configuration directory.", nargs="*" diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index d3f4d151c21..88ecda60b93 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -147,9 +147,9 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: from esphome.core import CORE CORE.name = TIDY_PROJECT_NAME - # config_path's parent is the data dir root: the IDF install lives at - # ``/.esphome/idf`` -- keep it beside (not inside) the per-run - # project dir so clearing the project doesn't force an IDF re-download. + # config_path's parent is the data dir root for per-run artifacts (idedata, + # converted pio_components). The IDF install is in the global cache dir, + # independent of this path. CORE.config_path = work_dir.parent / "tidy.yaml" CORE.build_path = work_dir esp32 = CORE.data.setdefault(KEY_ESP32, {}) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c994ce2410c..25283e3c99d 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,6 +9,8 @@ import re import shutil import tempfile +import platformdirs + from esphome.config_validation import Version from esphome.core import CORE from esphome.framework_helpers import ( @@ -80,10 +82,18 @@ def _get_idf_tools_path() -> Path: Returns: Path object pointing to the ESP-IDF tools directory """ - if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + # Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("") + # resolves to the CWD, which would install into (and let clean-all delete) + # the working directory by accident. + if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip(): + path = Path(prefix).expanduser() else: - path = CORE.data_dir / "idf" + # Machine-global so all projects share the multi-GB install instead of + # a per-config-directory copy. The user cache dir (not ~/.esphome) + # avoids colliding with data_dir when configs live in the home dir. + # appauthor=False drops the redundant \ segment on Windows + # (which otherwise repeats "esphome\esphome\") to keep the path short. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which # otherwise warns that the venv interpreter path doesn't match the install. @@ -145,10 +155,11 @@ def _check_windows_path_length() -> None: " fatal error: bits/c++config.h: No such file or directory\n" " cannot execute 'as': CreateProcess: No such file or directory\n" "To fix, either:\n" - " - Enable Windows long path support: set\n" - " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" - " to 1 and reboot, or\n" - " - Move your ESPHome project to a shorter path\n" + " - Enable Windows long path support, then reboot. In an elevated\n" + " PowerShell run:\n" + " Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' LongPathsEnabled 1\n" + " Details: https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation\n" + " - Or set ESPHOME_ESP_IDF_PREFIX to a shorter path (e.g. C:\\ESPHome\\idf)\n" "Then delete the ESP-IDF tools directory above so the toolchain " "reinstalls cleanly.", tools_path, @@ -553,7 +564,7 @@ def _check_esphome_idf_framework_install( # Logged every invocation (not just on install) so the user can verify the # override. A changed URL needs ``esphome clean-all`` to force a re-download # (``esphome clean`` only wipes the build dir, not the extracted framework - # under /idf/frameworks/). + # under the global install dir's ``frameworks/``). if source_url: _LOGGER.info("Using framework source override: %s", source_url) @@ -822,11 +833,9 @@ def _ccache_env() -> dict[str, str]: Enabled by default whenever the ``ccache`` binary is on PATH; set ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under - the IDF tools path. How widely it is shared depends on where that resolves: - across projects (and surviving ``clean-all``) when it is a common location - (``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under - ``.esphome/idf`` for a default pip install, where ``clean-all`` clears it - along with the framework. + the IDF tools path (the machine-global cache dir, or + ``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed + by ``esphome clean-all`` along with the framework. Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build diff --git a/esphome/writer.py b/esphome/writer.py index a9c072f1562..52f2d169b35 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,6 +653,15 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) + # The native ESP-IDF install lives in a machine-global cache dir, outside + # any .esphome data dir, so the per-config loop above won't reach it. + from esphome.espidf.framework import _get_idf_tools_path + + idf_install_path = _get_idf_tools_path() + if idf_install_path.is_dir(): + _LOGGER.info("Deleting %s", idf_install_path) + rmtree(idf_install_path) + # Clean PlatformIO project files try: from platformio.project.config import ProjectConfig diff --git a/requirements.txt b/requirements.txt index 85f4b56c079..3832045cfcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 py7zr==1.1.3 +platformdirs==4.9.4 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index fe888ac8b92..f3e160925a8 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -36,6 +36,19 @@ from esphome.espidf.framework import ( from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path +@pytest.fixture(autouse=True) +def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the ESP-IDF install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the framework dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the env + themselves. + """ + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install")) + + @pytest.mark.parametrize( ("source", "expected"), [ @@ -791,6 +804,38 @@ def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: assert _get_idf_tools_path() == Path(override) +@pytest.mark.parametrize("value", ["", " "]) +def test_get_idf_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could then + delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert _get_idf_tools_path() == expected + + +def test_get_idf_tools_path_default_uses_user_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without the env override the install root is the machine-global OS user + cache dir, not the per-config ``/idf``.""" + import platformdirs + + monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert _get_idf_tools_path() == expected + + def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: with patch("pathlib.Path.write_text", side_effect=OSError("denied")): # write failure is caught and warned, not raised @@ -908,3 +953,5 @@ def test_check_windows_path_length_long_path_warns( message = caplog.records[0].getMessage() assert _LONG_IDF_PATH in message assert "long path support" in message + # The install is global now; the remedy is the prefix env, not moving the project. + assert "ESPHOME_ESP_IDF_PREFIX" in message diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index c8cf68ff3e3..18d08e7cb1d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -67,15 +67,23 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: want to verify the PIO-cleanup branch (e.g. test_clean_all, test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. + + Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the + same reason: ``clean_all`` removes the now machine-global ESP-IDF + install, which otherwise defaults to the real ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" ) - with patch( - "platformio.project.config.ProjectConfig.get_instance", - return_value=mock_cfg, + with ( + patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ), + patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}), ): yield @@ -990,6 +998,30 @@ def test_clean_all_with_yaml_file( assert str(build_dir) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_idf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native ESP-IDF install dir.""" + idf_install = tmp_path / "idf_install" + (idf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not idf_install.exists() + assert str(idf_install.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, From 990431aa5bf201c02d8560ddc30efe34d195d748 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:30 -0400 Subject: [PATCH 231/343] [bluetooth_proxy] Fix -Wtype-limits warning with active: false (#17273) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 10449f21f1e..2b6d29da433 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - void register_connection(BluetoothConnection *connection) { + // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. + void register_connection([[maybe_unused]] BluetoothConnection *connection) { + // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; } +#endif } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); From 9468ad628cdf848590ea8238cf5a77e604b54c9f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:57:34 -0400 Subject: [PATCH 232/343] [espnow] Drop oversized received frames to prevent buffer overflow (#17271) --- esphome/components/espnow/espnow_component.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 2756b615a13..91f2c067ca7 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) } void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a + // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), + // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger + // frame would overflow packet_.receive.data. + if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + // Allocate an event from the pool ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); if (packet == nullptr) { @@ -327,13 +336,13 @@ void ESPNowComponent::loop() { // Log dropped received packets periodically uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); if (received_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped); } // Log dropped send packets periodically uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); if (send_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); } } From 8a3d0aeafb61c8a10f8f118918b983c7f0279bbc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:33:17 -0400 Subject: [PATCH 233/343] [tests] Add esp32-c61-idf base file for grouped component tests (#17293) --- .../build_components_base.esp32-c61-idf.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_build_components/build_components_base.esp32-c61-idf.yaml diff --git a/tests/test_build_components/build_components_base.esp32-c61-idf.yaml b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml new file mode 100644 index 00000000000..e1bd4645cce --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32c61idf + friendly_name: $component_name + +esp32: + variant: ESP32C61 + flash_size: 8MB + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 1b556f5d0cd45c8d3ae790aaead09676a9858137 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:17:47 -0400 Subject: [PATCH 234/343] [ethernet] Fix ETH_SPEED_1000M build on IDF 6.0 (enum added in 6.1) (#17311) --- esphome/components/ethernet/ethernet_component_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 7a1bcae42fc..5ad1e7d483c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -839,7 +839,7 @@ void EthernetComponent::dump_connect_params_() { case ETH_SPEED_100M: link_speed = 100; break; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) case ETH_SPEED_1000M: link_speed = 1000; break; From c8b37fb1c8bb735707988b65c96f913358d1b189 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:18:33 -0400 Subject: [PATCH 235/343] Bump platformdirs from 4.9.4 to 4.10.0 (#17309) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3832045cfcc..4237ad0f81e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.9.4 # native esp-idf toolchain global cache dir +platformdirs==4.10.0 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 4c9ed129cfb825a30ecd989c2d76f265f6332cb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:28:04 -0400 Subject: [PATCH 236/343] Bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.3 (#17310) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ac55aa0066..2016739c4fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -820,7 +820,7 @@ jobs: run: echo ${{ matrix.components }} - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: packages: libsdl2-dev ccache version: 1.1 From 848defedd87eb9943afacec249b9a4f6b6300a1e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:09:48 +1200 Subject: [PATCH 237/343] Bump bundled esphome-device-builder to 1.0.23 (#17316) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 04e7998f777..af80d01496e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.22 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 RUN \ platformio settings set enable_telemetry No \ From 0e260e5cbbe14e5a8aa7f0f8aeeac8ea20e1a931 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:52:09 -0500 Subject: [PATCH 238/343] Bump bundled esphome-device-builder to 1.0.24 (#17332) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index af80d01496e..064ba2a3588 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 RUN \ platformio settings set enable_telemetry No \ From d25d1606867972060c22d2c7dea9118ae89a408d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 1 Jul 2026 13:01:48 -0700 Subject: [PATCH 239/343] [modbus_server] Fix register range issues and allow partial reads (#17205) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/modbus_server/__init__.py | 55 +++++- esphome/components/modbus_server/const.py | 1 + .../modbus_server/modbus_server.cpp | 107 ++++++++---- .../components/modbus_server/modbus_server.h | 6 + .../modbus_server/test_modbus_server.py | 84 +++++++++ tests/components/modbus_server/common.yaml | 1 + .../modbus_server/modbus_server_test.cpp | 161 ++++++++++++++++++ 7 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 tests/component_tests/modbus_server/test_modbus_server.py diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 2ba7f41b832..14f4ca8a4d7 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -8,8 +8,10 @@ from esphome.components.modbus.helpers import ( ) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType from .const import ( + CONF_ALLOW_PARTIAL_READ, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -41,17 +43,62 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( } ) +# RAW has no numeric encoding, so it is not a valid server register type: a server value is produced by a +# lambda and encoded into registers, and on the server a RAW register would just be a single 16-bit word -- +# use U_WORD for that. Restrict the choices to the encodable types. +SERVER_SENSOR_VALUE_TYPE = { + key: value for key, value in SENSOR_VALUE_TYPE.items() if key != "RAW" +} + ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( + SERVER_SENSOR_VALUE_TYPE + ), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_ALLOW_PARTIAL_READ, default=False): cv.boolean, } ) +def _validate_register_ranges(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit + # Modbus address space (0x0000-0xFFFF). + for register in config.get(CONF_REGISTERS, []): + address = register[CONF_ADDRESS] + register_count = TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]] + if address + register_count > 0x10000: + raise cv.Invalid( + f"Register at 0x{address:04X} spans {register_count} register(s) and runs past " + "the end of the 16-bit address space (0xFFFF)", + path=[CONF_REGISTERS], + ) + return config + + +def _validate_no_overlapping_registers(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count). Reject configs where any two ranges + # overlap -- the same address twice, or a multi-register value straddling a neighbour -- since the + # server resolves a request by the value containing an address and overlaps are ambiguous. + spans = sorted( + (register[CONF_ADDRESS], TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]]) + for register in config.get(CONF_REGISTERS, []) + ) + for (address, register_count), (next_address, _) in zip( + spans, spans[1:], strict=False + ): + if next_address < address + register_count: + raise cv.Invalid( + f"Register address 0x{next_address:04X} overlaps the register at 0x{address:04X}, " + f"which spans {register_count} register(s); each register's address range must be unique", + path=[CONF_REGISTERS], + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -62,10 +109,12 @@ CONFIG_SCHEMA = cv.All( ): cv.ensure_list(ModbusServerRegisterSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), + _validate_register_ranges, + _validate_no_overlapping_registers, ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_server", role="server")(config) @@ -118,6 +167,8 @@ async def to_code(config): ), ) ) + if server_register[CONF_ALLOW_PARTIAL_READ]: + cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f83211c207b..f2a8c53f45c 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,3 +5,4 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index bb264eb9933..44b1b160a5d 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -8,6 +8,25 @@ using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; +// The widest Modbus value type (QWORD) spans four registers. +static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4; +// number_to_payload() encodes the 64-bit value returned by read_lambda() into 16-bit registers, so the +// widest possible value spans exactly sizeof(int64_t) / sizeof(uint16_t) registers. Tie the bound to that +// source so a future wider value type -- which would require widening the encoded value itself -- can't +// silently overflow the value_words buffer below (StaticVector::push_back drops words past capacity). +static_assert(MAX_REGISTERS_PER_VALUE == sizeof(int64_t) / sizeof(uint16_t), + "MAX_REGISTERS_PER_VALUE must match the register span of the widest encodable value"); + +ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const { + for (auto *server_register : this->server_registers_) { + if (address >= server_register->address && + address < static_cast(server_register->address) + server_register->register_count) { + return server_register; + } + } + return nullptr; +} + modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) { @@ -15,42 +34,68 @@ modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t sta "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool found = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - if (!server_register->read_lambda) { - break; - } - int64_t value = server_register->read_lambda(); - char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; - ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", - server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); + const uint32_t end_address = static_cast(start_address) + number_of_registers; + uint32_t current_address = start_address; + while (current_address < end_address) { + ServerRegister *server_register = this->find_containing_register_(current_address); - modbus::helpers::number_to_payload(registers, value, server_register->value_type); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { + if (server_register == nullptr) { + // Unregistered address: optionally answer with the courtesy default, otherwise reject. if (this->server_courtesy_response_.enabled && - (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGV(TAG, - "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %" PRIu16 ".", - current_address, this->server_courtesy_response_.register_value); + current_address <= this->server_courtesy_response_.register_last_address) { + ESP_LOGV(TAG, "No register at 0x%04X; returning courtesy default %" PRIu16 ".", + static_cast(current_address), this->server_courtesy_response_.register_value); registers.push_back(this->server_courtesy_response_.register_value); - current_address += 1; // Just increment by 1, as the default response is a single register - } else { - ESP_LOGW(TAG, - "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", - current_address); - return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + current_address += 1; // the courtesy default is always a single register + continue; } + ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.", + static_cast(current_address)); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } + + if (!server_register->read_lambda) { + // Registered but not readable (write-only); don't mask it with the courtesy default. + ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + // A multi-register value is normally atomic: the request must start at its first register and cover all of + // it. A value may opt in to partial reads, in which case the request may start inside it or stop short of + // its end and we return only the covered words. + const uint16_t value_offset = static_cast(current_address - server_register->address); + const uint16_t words_available = static_cast(server_register->register_count - value_offset); + const uint16_t words_wanted = static_cast(end_address - current_address); + const uint16_t take = words_available < words_wanted ? words_available : words_wanted; + const bool clipped = value_offset != 0 || take != server_register->register_count; + if (clipped && !server_register->allow_partial_read) { + ESP_LOGW(TAG, + "Read clips the multi-register value at 0x%04X, which does not allow partial reads. " + "Sending exception response.", + server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; + ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", + server_register->address, static_cast(server_register->value_type), + server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); + + // Encode the whole value once (wire word order) and emit only the covered words. Slicing the encoded words + // handles the reversed value types for free, since number_to_payload already emits in wire order. + StaticVector value_words; + modbus::helpers::number_to_payload(value_words, value, server_register->value_type); + if (value_offset + take > value_words.size()) { + // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault. + ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address, + server_register->register_count); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; + } + for (uint16_t i = 0; i < take; i++) { + registers.push_back(value_words[value_offset + i]); + } + current_address += take; } return {}; diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0c224545286..f68d1c4a30f 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -84,9 +84,13 @@ class ServerRegister { } } + void set_allow_partial_read(bool allow_partial_read) { this->allow_partial_read = allow_partial_read; } + uint16_t address{0}; SensorValueType value_type{SensorValueType::RAW}; uint8_t register_count{0}; + // When true, a read may cover only part of this multi-register value; otherwise it must read the whole value. + bool allow_partial_read{false}; ReadLambda read_lambda; WriteLambda write_lambda; }; @@ -111,6 +115,8 @@ class ModbusServer : public Component, public modbus::ModbusServerDevice { ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: + /// Find the registered value whose register span contains address, or nullptr if none does. + ServerRegister *find_containing_register_(uint32_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; /// Server courtesy response diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py new file mode 100644 index 00000000000..7c978a5cd54 --- /dev/null +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -0,0 +1,84 @@ +"""Tests for modbus_server configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.modbus_server import ( + SERVER_SENSOR_VALUE_TYPE, + _validate_no_overlapping_registers, + _validate_register_ranges, +) +from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE +from esphome.const import CONF_ADDRESS + + +def _config(registers: list[tuple[int, str]]) -> dict: + return { + CONF_REGISTERS: [ + {CONF_ADDRESS: address, CONF_VALUE_TYPE: value_type} + for address, value_type in registers + ] + } + + +def test_non_overlapping_registers_pass() -> None: + # Values that tile the address space without gaps or overlaps are accepted. + config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_registers_with_gaps_pass() -> None: + config = _config([(0x00, "U_WORD"), (0x05, "U_QWORD"), (0x20, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_no_registers_pass() -> None: + assert _validate_no_overlapping_registers({}) == {} + + +def test_duplicate_address_rejected() -> None: + config = _config([(0x10, "U_WORD"), (0x10, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_multi_register_value_overlapping_neighbour_rejected() -> None: + # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. + config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_overlap_detected_regardless_of_order() -> None: + # The U_DWORD at 0x10 covers 0x10-0x11 and overlaps the U_WORD at 0x11 even when declared after it. + config = _config([(0x11, "U_WORD"), (0x10, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_register_span_within_address_space_pass() -> None: + # A value whose span ends exactly at 0xFFFF is fine (U_QWORD at 0xFFFC covers 0xFFFC-0xFFFF). + config = _config([(0xFFFF, "U_WORD"), (0xFFFC, "U_QWORD")]) + assert _validate_register_ranges(config) is config + + +def test_register_span_past_end_rejected() -> None: + # U_QWORD at 0xFFFE would need 0xFFFE-0x10001, running off the 16-bit address space. + config = _config([(0xFFFE, "U_QWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_multi_register_value_at_last_address_rejected() -> None: + # A U_DWORD at 0xFFFF needs a second register at 0x10000, which does not exist. + config = _config([(0xFFFF, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_raw_value_type_rejected() -> None: + # RAW has no numeric encoding, so it is not offered as a server register type. + validator = cv.enum(SERVER_SENSOR_VALUE_TYPE) + with pytest.raises(cv.Invalid): + validator("RAW") + assert validator("U_WORD") == "U_WORD" diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 2e4a81a1aa5..8b2316b6e3c 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -18,6 +18,7 @@ modbus_server: registers: - address: 0x9 value_type: S_DWORD + allow_partial_read: true read_lambda: |- return 31; write_lambda: |- diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 0c8f5d04cf0..419bb9cf25d 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -121,4 +121,165 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } +// --- on_modbus_read_registers -------------------------------------------------- + +TEST(ModbusServerRead, SingleWordSucceeds) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0x1234); + EXPECT_EQ(out[1], 0x5678); +} + +// Starting inside a multi-register value is rejected with ILLEGAL_DATA_ADDRESS -- not masked by the courtesy +// default -- and the read_lambda is never invoked. +TEST(ModbusServerRead, StartInsideValueRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); // occupies 0x0010 and 0x0011 + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A read that stops short of a value's end clips it -> ILLEGAL_DATA_ADDRESS, and the read_lambda is not invoked. +TEST(ModbusServerRead, ClippedTailRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A write-only register (no read_lambda) is not readable -> ILLEGAL_DATA_ADDRESS, not a courtesy default. +TEST(ModbusServerRead, WriteOnlyRegisterRejected) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); // no read_lambda set + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0000, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An unregistered address with courtesy enabled returns the default value for each cell. +TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { + ModbusServer server; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0005, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0xABCD); + EXPECT_EQ(out[1], 0xABCD); +} + +// An unregistered address with courtesy disabled is rejected. +TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { + ModbusServer server; + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// --- partial reads (opt-in) ---------------------------------------------------- + +// With allow_partial_read, reading only the first register of a DWORD returns its high word. +TEST(ModbusServerRead, PartialReadHighWord) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0010, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +// With allow_partial_read, starting at the interior cell returns the low word. +TEST(ModbusServerRead, PartialReadLowWordFromInterior) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_modbus_read_registers(0x0011, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x5678); +} + +// Slicing is in wire order, so a reversed value type partials correctly: U_DWORD_R emits the low word +// first, so 0x0010 holds 0x5678 and 0x0011 holds 0x1234. +TEST(ModbusServerRead, PartialReadReversedType) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD_R, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues first; + ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value()); + ASSERT_EQ(first.size(), 1u); + EXPECT_EQ(first[0], 0x5678); + + RegisterValues second; + ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value()); + ASSERT_EQ(second.size(), 1u); + EXPECT_EQ(second[0], 0x1234); +} + } // namespace esphome::modbus_server From 0427d20c5b87e808801f95c98427635a441e8762 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:01 -0500 Subject: [PATCH 240/343] Bump bundled esphome-device-builder to 1.0.25 (#17333) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 064ba2a3588..543f17db56e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.24 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 RUN \ platformio settings set enable_telemetry No \ From e4a68c2da3461663ce4dcd24409e5e5494469a48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:25:55 -0500 Subject: [PATCH 241/343] Bump pillow from 12.2.0 to 12.3.0 (#17335) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4237ad0f81e..baa8b5efd20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -pillow==12.2.0 +pillow==12.3.0 resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 From 7522780c67c7d4846526c91bd11bf8f9d8153a8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jul 2026 18:25:34 -0500 Subject: [PATCH 242/343] [esp8266] Strip dead libstdc++ throw message strings from DRAM (#17341) --- esphome/components/esp8266/__init__.py | 8 +++++ esphome/components/esp8266/throw_stubs.h | 41 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 esphome/components/esp8266/throw_stubs.h diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 4daf4549ef2..b658feb76aa 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -310,6 +310,14 @@ async def to_code(config): # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` cg.add_build_flag("-DNEW_OOM_ABORT") + # Force-include inline std::__throw_* overrides so GCC dead-strips the unused + # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. + # See throw_stubs.h for details. Must be prepended before , so this + # uses build_src_flags with -include. + cg.add_platformio_option( + "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" + ) + # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing # we pretend it has much larger memory to test that components compile together diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h new file mode 100644 index 00000000000..a650935a5e2 --- /dev/null +++ b/esphome/components/esp8266/throw_stubs.h @@ -0,0 +1,41 @@ +#pragma once +/* + * Inline overrides for std::__throw_* helpers (ESP8266). + * + * ESP8266 Arduino compiles with -fno-exceptions and ships a libstdc++ whose + * std::__throw_* functions already just call abort() -- they never read their + * const char* message argument. But the compiler still emits the message load + * at every throw site (inside header-instantiated std::string / std::vector + * code), so --gc-sections keeps those libstdc++ error strings alive. On + * ESP8266 .rodata lives in DRAM, so each one wastes scarce RAM (e.g. + * "basic_string::_M_construct null not valid", "basic_string::_M_create", + * "cannot create std::vector larger than max_size()", "array::at: ..."). + * + * Providing inline definitions here lets GCC see the message argument is + * unused, dead-strip the load, and drop the string entirely -- no LTO needed. + * Behavior is identical to today: a bare abort() (the message was never + * printed). This header MUST be force-included before , so it is + * wired up via build_src_flags "-include ..." in this component's __init__.py. + * + * Note: this defines functions in namespace std (technically UB). It is safe + * here because the definitions match the existing abort() behavior exactly. + */ + +#ifdef __cplusplus + +// Empty namespace so the CI namespace check is satisfied; the overrides below +// must live in namespace std, so they cannot go in the component namespace. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +// NOLINTBEGIN(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) +namespace std { + +__attribute__((__noreturn__)) inline void __throw_logic_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_length_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range_fmt(const char *, ...) { __builtin_abort(); } + +} // namespace std +// NOLINTEND(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) + +#endif // __cplusplus From 4a7c58d5aed36741527ae890aec3ae6cb9d96b11 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 2 Jul 2026 03:26:56 +0200 Subject: [PATCH 243/343] [usb_uart] Fix format specifier warnings for uint32_t in ft23xx and pl2303 (#17342) --- esphome/components/usb_uart/ft23xx.cpp | 7 ++++--- esphome/components/usb_uart/pl2303.cpp | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 3b0e05ba537..2e8ff8bcb57 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -6,6 +6,7 @@ #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" +#include namespace esphome::usb_uart { @@ -288,16 +289,16 @@ int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); channel->initialised_.store(false); } else { - ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); + ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_); this->set_line_properties_(channel); } }; if (baudrate == 0) { baudrate = channel->baud_rate_; } - uint16_t value, ftdi_index; + uint16_t value = 0, ftdi_index = 0; ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); + ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); if (!ok) { diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3685debef4c..134c51198df 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -3,6 +3,7 @@ #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include namespace esphome::usb_uart { @@ -282,8 +283,8 @@ void USBUartTypePL2303::enable_channels() { // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; From 5b8bf510226d47c8b33fe0e4a7342e1c81001e77 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:56:01 +1000 Subject: [PATCH 244/343] [power_supply] Make enable_on_boot high priority (#16914) --- .../components/power_supply/power_supply.cpp | 6 ++- esphome/core/component.h | 2 + .../power_supply/test_setup_priority.cpp | 47 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/components/power_supply/test_setup_priority.cpp diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 4da73e76ae0..f094f6e2e98 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -21,7 +21,11 @@ void PowerSupply::dump_config() { LOG_PIN(" Pin: ", this->pin_); } -float PowerSupply::get_setup_priority() const { return setup_priority::IO; } +float PowerSupply::get_setup_priority() const { + if (this->pin_->is_internal() && this->enable_on_boot_) + return setup_priority::POWER; + return setup_priority::IO; +} bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } diff --git a/esphome/core/component.h b/esphome/core/component.h index 1ae70371a19..70a051ca0b2 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -33,6 +33,8 @@ class RuntimeStatsCollector; */ namespace setup_priority { +/// For power supply components that must be on before buses like i2c can work. +inline constexpr float POWER = 1200.0f; /// For communication buses like i2c/spi inline constexpr float BUS = 1000.0f; /// For components that represent GPIO pins like PCF8573 diff --git a/tests/components/power_supply/test_setup_priority.cpp b/tests/components/power_supply/test_setup_priority.cpp new file mode 100644 index 00000000000..401fc72654e --- /dev/null +++ b/tests/components/power_supply/test_setup_priority.cpp @@ -0,0 +1,47 @@ +#include + +#include "esphome/components/power_supply/power_supply.h" +#include "esphome/core/gpio.h" +#include "esphome/core/component.h" + +namespace esphome::power_supply::testing { + +// Minimal dummy internal GPIO pin implementation for testing +class DummyInternalPin : public InternalGPIOPin { + public: + DummyInternalPin() = default; + void setup() override {} + void pin_mode(esphome::gpio::Flags) override {} + esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool) override {} + void detach_interrupt() const override {} + ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); } + uint8_t get_pin() const override { return 0; } + bool is_inverted() const override { return false; } + + protected: + // Implement protected attach_interrupt required by InternalGPIOPin + void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {} +}; + +TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(true); + + // POWER priority should be greater than BUS priority + EXPECT_GT(ps.get_setup_priority(), setup_priority::BUS); +} + +TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(false); + + EXPECT_EQ(ps.get_setup_priority(), setup_priority::IO); +} + +} // namespace esphome::power_supply::testing From 0666cb86355731ebf6bf429c6f7e06c7b8e11b35 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 2 Jul 2026 05:44:09 +0200 Subject: [PATCH 245/343] [usb_uart] Add per-device-type maximum baud rate cap (#17259) --- esphome/components/usb_uart/__init__.py | 70 ++++++++++++++++--------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index e42a2c092bb..a921b6fbf05 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -46,42 +46,59 @@ DEFAULT_BAUD_RATE = 9600 class Type: - def __init__(self, name, vid, pid, cls, max_channels=1, baud_rate_required=True): + def __init__( + self, + name, + vid, + pid, + cls, + max_channels=1, + baud_rate_required=True, + max_baud=1_000_000, + ): self.name = name cls = cls or name self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) - self.max_channels = max_channels + self._max_channels = max_channels self.baud_rate_required = baud_rate_required + self.max_baud = max_baud + + @property + def max_channels(self) -> int: + return ( + 3 + if ( + CORE.is_esp32 + and get_esp32_variant() != VARIANT_ESP32P4 + and self._max_channels > 3 + ) + else self._max_channels + ) uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("FT232", 0x0403, 0x6001, "FT23XX", 1), - Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), - Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), - Type("PL2303", 0x067B, 0x2303, "PL2303", 1), - Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), - Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), - Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), - Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), - Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), - Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) -def channel_schema(channels, baud_rate_required): - # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and - # there are only a total of 8 endpoints available. - # This will need updating when the 8 channel devices that multiplex over an endpoint are added. - if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: - channels = 3 +def channel_schema(type_: "Type") -> cv.Schema: return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( @@ -94,11 +111,11 @@ def channel_schema(channels, baud_rate_required): ), ( cv.Required(CONF_BAUD_RATE) - if baud_rate_required + if type_.baud_rate_required else cv.Optional( CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE ) - ): cv.int_range(min=300, max=1000000), + ): cv.int_range(min=300, max=type_.max_baud), cv.Optional(CONF_STOP_BITS, default="1"): cv.enum( UART_STOP_BITS_OPTIONS, upper=True ), @@ -117,7 +134,10 @@ def channel_schema(channels, baud_rate_required): } ) ), - cv.Length(max=channels), + cv.Length( + max=type_.max_channels, + msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", + ), ) } ) @@ -127,7 +147,7 @@ CONFIG_SCHEMA = cv.ensure_list( cv.typed_schema( { it.name: usb_device_schema(it.cls, it.vid, it.pid).extend( - channel_schema(it.max_channels, it.baud_rate_required) + channel_schema(it) ) for it in uart_types }, From 06c7ac37d13eaeadfbe16db9150d9b28aa66b3d9 Mon Sep 17 00:00:00 2001 From: Twisterss Date: Thu, 2 Jul 2026 09:33:58 +0200 Subject: [PATCH 246/343] [epaper_spi] Add Waveshare 7.5" V2 BWR support (#15719) --- .../epaper_spi/epaper_waveshare_bwr.cpp | 146 ++++++++++++++++++ .../epaper_spi/epaper_waveshare_bwr.h | 40 +++++ .../epaper_spi/models/waveshare_bwr.py | 56 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++ 4 files changed, 263 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_waveshare_bwr.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare_bwr.h create mode 100644 esphome/components/epaper_spi/models/waveshare_bwr.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp new file mode 100644 index 00000000000..004597b72bd --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -0,0 +1,146 @@ +#include "epaper_waveshare_bwr.h" + +#include + +namespace esphome::epaper_spi { + +enum class BwrState : uint8_t { + BWR_BLACK, + BWR_WHITE, + BWR_RED, +}; + +static BwrState color_to_bwr(Color color) { + if (color.r > color.g + color.b && color.r > 127) { + return BwrState::BWR_RED; + } + if (color.r + color.g + color.b >= 382) { + return BwrState::BWR_WHITE; + } + return BwrState::BWR_BLACK; +} + +// UC8179 3-color display buffer layout: +// - 1 bit per pixel, 8 pixels per byte +// - Buffer first half: Black/White plane (1=black, 0=white) +// - Buffer second half: Red plane (1=red, 0=white) +// - Total: row_width * height * 2 bytes + +void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const uint32_t pos = (x / 8) + (y * this->row_width_); + const uint8_t bit = 0x80 >> (x & 0x07); + const uint32_t red_offset = this->buffer_length_ / 2u; + + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + this->buffer_[pos] |= bit; + } else { + this->buffer_[pos] &= ~bit; + } + + if (bwr == BwrState::BWR_RED) { + this->buffer_[red_offset + pos] |= bit; + } else { + this->buffer_[red_offset + pos] &= ~bit; + } +} + +void EPaperWaveshareBWR::fill(Color color) { + const size_t half_buffer = this->buffer_length_ / 2u; + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + // Black plane: 0xFF (black), Red plane: 0x00 (no red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0xFF; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0x00; + } else if (bwr == BwrState::BWR_RED) { + // Black plane: 0x00 (no black), Red plane: 0xFF (red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0x00; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0xFF; + } else { + // Black plane: 0x00 (no black), Red plane: 0x00 (no red) + this->buffer_.fill(0x00); + } +} + +bool HOT EPaperWaveshareBWR::transfer_data() { + const uint32_t start_time = millis(); + const size_t buffer_length = this->buffer_length_; + const size_t half_buffer = buffer_length / 2u; + + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + + // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) + // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + if (this->current_data_index_ < half_buffer) { + if (this->current_data_index_ == 0) { + this->command(0x10); // DATA START TRANSMISSION 1 (black channel) + } + this->start_data_(); + while (this->current_data_index_ < half_buffer) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + // Phase 2: send Red plane (second half) via command 0x13 (DTM2) + // UC8179 DTM2 (0x13): 1=red, 0=white + if (this->current_data_index_ < buffer_length) { + if (this->current_data_index_ == half_buffer) { + this->command(0x13); // DATA START TRANSMISSION 2 (red channel) + } + this->start_data_(); + while (this->current_data_index_ < buffer_length) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperWaveshareBWR::power_on() { + this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING + this->command(0x04); // POWER ON +} + +void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { + this->command(0x12); // DISPLAY REFRESH +} + +void EPaperWaveshareBWR::power_off() { + this->command(0x02); // POWER OFF +} + +void EPaperWaveshareBWR::deep_sleep() { + this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h new file mode 100644 index 00000000000..a090faa14d2 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -0,0 +1,40 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare 3-color e-paper displays (UC8179 controller). + * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * + * Color scheme: Black, White, Red (BWR) + * Buffer layout: 1 bit per pixel, separate planes + * - Buffer first half: Black/White plane (1=black, 0=white) + * - Buffer second half: Red plane (1=red, 0=no red) + * - Total buffer: width * height / 4 bytes (2 * width * height / 8) + * + * The init sequence (INITIALISE state) sends panel configuration only. + * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). + */ +class EPaperWaveshareBWR : public EPaperBase { + public: + EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + this->buffer_length_ = this->row_width_ * height * 2; + } + + void fill(Color color) override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py new file mode 100644 index 00000000000..e124ea7083b --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -0,0 +1,56 @@ +"""Waveshare Black/White/Red e-paper displays using UC8179 controller. + +Supported models: +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) + +These displays use the UC8179 controller. Panel configuration is sent during +the INITIALISE state. Power-on is handled in the POWER_ON state, after data +transfer, so the state machine's built-in busy wait covers the power-on delay. +""" + +from . import EpaperModel + + +class WaveshareBWR(EpaperModel): + """EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller.""" + + def __init__(self, name, **defaults): + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def get_init_sequence(self, config): + """Generate initialization sequence for UC8179 BWR displays. + + Panel configuration only — power-on is handled separately in power_on() + after data transfer, with the state machine busy-waiting before refresh. + """ + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (KWR mode) + (0x00, 0x0F), + # RESOLUTION SETTING (width x height) + ( + 0x61, + (width >> 8) & 0xFF, + width & 0xFF, + (height >> 8) & 0xFF, + height & 0xFF, + ), + # DUAL SPI MODE (disabled) + (0x15, 0x00), + # VCOM AND DATA INTERVAL SETTING + (0x50, 0x11, 0x07), + # TCON SETTING + (0x60, 0x22), + # RESOLUTION GATE SETTING + (0x65, 0x00, 0x00, 0x00, 0x00), + ) + + +# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +WaveshareBWR( + "waveshare-7.5in-bv2-bwr", + width=800, + height=480, + data_rate="10MHz", + minimum_update_interval="30s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 60e4008f4f1..bb771f2132a 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -203,3 +203,24 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + + # Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-7.5in-bv2-bwr + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0)); From 792dfbcbbf116002468ed3692596fa5aa88b9448 Mon Sep 17 00:00:00 2001 From: Sven Kocksch Date: Thu, 2 Jul 2026 10:40:59 +0200 Subject: [PATCH 247/343] [st7123] add ST7123 touch controller component (M5Stack Tab5) (#12075) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/st7123/__init__.py | 6 + .../components/st7123/touchscreen/__init__.py | 32 ++++++ .../st7123/touchscreen/st7123_touchscreen.cpp | 108 ++++++++++++++++++ .../st7123/touchscreen/st7123_touchscreen.h | 48 ++++++++ tests/components/st7123/common.yaml | 18 +++ tests/components/st7123/test.esp32-idf.yaml | 9 ++ 7 files changed, 222 insertions(+) create mode 100644 esphome/components/st7123/__init__.py create mode 100644 esphome/components/st7123/touchscreen/__init__.py create mode 100644 esphome/components/st7123/touchscreen/st7123_touchscreen.cpp create mode 100644 esphome/components/st7123/touchscreen/st7123_touchscreen.h create mode 100644 tests/components/st7123/common.yaml create mode 100644 tests/components/st7123/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d2c92f44ce9..b222c442149 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -501,6 +501,7 @@ esphome/components/ssd1331_base/* @kbx81 esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 +esphome/components/st7123/* @miniskipper esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita diff --git a/esphome/components/st7123/__init__.py b/esphome/components/st7123/__init__.py new file mode 100644 index 00000000000..335bc238be8 --- /dev/null +++ b/esphome/components/st7123/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@miniskipper"] +DEPENDENCIES = ["i2c"] + +st7123_ns = cg.esphome_ns.namespace("st7123") diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py new file mode 100644 index 00000000000..5ebd08066f0 --- /dev/null +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -0,0 +1,32 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import st7123_ns + +ST7123Touchscreen = st7123_ns.class_( + "ST7123Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(ST7123Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } +).extend(i2c.i2c_device_schema(0x55)) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp new file mode 100644 index 00000000000..117f9752645 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -0,0 +1,108 @@ +#include "st7123_touchscreen.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::st7123 { + +static const char *const TAG = "st7123.touchscreen"; + +void ST7123Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms) + delay(5); + this->reset_pin_->digital_write(true); + // The controller needs up to 20ms to initialize after reset before it can be accessed. + this->setup_time_ = millis() + 30; + } +} + +void ST7123Touchscreen::update() { + // check if setup is complete + if (this->setup_time_ != 0) { + if (this->setup_time_ > millis()) + return; + + uint8_t status; + if (this->read_register16(ST7123_REG_STATUS, &status, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to read status register")); // will stop updates + return; + } + if ((status & 0x0F) == ST7123_STATUS_INIT) { + ESP_LOGD(TAG, "Controller still initializing"); + return; + } + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // INT is held high when idle and pulses low when touch data is ready. + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + ESP_LOGD(TAG, "Status is %X", status); + + uint8_t data; + if (this->read_register16(ST7123_REG_MAX_TOUCHES, &data, 1) == i2c::ERROR_OK && data != 0 && + data <= ST7123_MAX_TOUCHES) { + this->max_touches_ = data; + } + + // If no calibration was supplied, read the native coordinate resolution from the controller. + if (this->x_raw_max_ == this->x_raw_min_ || this->y_raw_max_ == this->y_raw_min_) { + uint8_t res[4]; + if (this->read_register16(ST7123_REG_MAX_X, res, sizeof(res)) == i2c::ERROR_OK) { + this->x_raw_max_ = encode_uint16(res[0] & ST7123_COORD_HIGH_MASK, res[1]); + this->y_raw_max_ = encode_uint16(res[2] & ST7123_COORD_HIGH_MASK, res[3]); + if (this->swap_x_y_) + std::swap(this->x_raw_max_, this->y_raw_max_); + } else { + this->mark_failed(LOG_STR("Failed to read calibration")); + return; + } + ESP_LOGD(TAG, "Read dimensions %d/%d", this->x_raw_max_, this->y_raw_max_); + } + this->setup_time_ = 0; // flag setup complete + } + Touchscreen::update(); +} + +void ST7123Touchscreen::update_touches() { + // Read the reporting table from the advanced touch info register through the last touch point. + // Reading from this register also clears the INT pin so the controller can report the next frame. + uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; + const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; + if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + this->skip_update_ = true; + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); + for (uint8_t i = 0; i != this->max_touches_; i++) { + const uint8_t *p = points + i * ST7123_TOUCH_STRIDE; + if ((p[0] & ST7123_TOUCH_VALID) == 0) + continue; + uint16_t x = encode_uint16(p[0] & ST7123_COORD_HIGH_MASK, p[1]); + uint16_t y = encode_uint16(p[2] & ST7123_COORD_HIGH_MASK, p[3]); + uint8_t intensity = p[5]; + ESP_LOGV(TAG, "Touch %u: x=%u, y=%u, intensity=%u", i, x, y, intensity); + this->add_raw_touch_position_(i, x, y, intensity); + } +} + +void ST7123Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "ST7123 Touchscreen:\n" + " Max touches: %u\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->max_touches_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::st7123 diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.h b/esphome/components/st7123/touchscreen/st7123_touchscreen.h new file mode 100644 index 00000000000..633eba7a826 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.h @@ -0,0 +1,48 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7123 { + +// Sitronix ST7123 capacitive touch controller. +// Registers are addressed with a 16-bit big-endian address (sent MSB first). +static constexpr uint16_t ST7123_REG_STATUS = 0x0001; // [7:4] error code, [3:0] device status +static constexpr uint16_t ST7123_REG_MAX_X = 0x0005; // 0x0005..0x0006 X resolution, 0x0007..0x0008 Y resolution +static constexpr uint16_t ST7123_REG_MAX_TOUCHES = 0x0009; +static constexpr uint16_t ST7123_REG_ADV_TOUCH_INFO = 0x0010; // start of the reporting table +static constexpr uint16_t ST7123_REG_TOUCH_DATA = 0x0014; // first touch point + +// Device status field of the status register. +static constexpr uint8_t ST7123_STATUS_INIT = 0x1; + +// Each touch point occupies 7 bytes: X high, X low, Y high, Y low, area, intensity, reserved. +static constexpr uint8_t ST7123_TOUCH_STRIDE = 7; +// Bit 7 of the X high byte indicates a valid touch point. +static constexpr uint8_t ST7123_TOUCH_VALID = 0x80; +// The X and Y high bytes only use the low 6 bits. +static constexpr uint8_t ST7123_COORD_HIGH_MASK = 0x3F; +// The ST7123 can report at most 10 touch points. +static constexpr uint8_t ST7123_MAX_TOUCHES = 10; + +class ST7123Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint8_t max_touches_{ST7123_MAX_TOUCHES}; + uint32_t setup_time_{1}; +}; + +} // namespace esphome::st7123 diff --git a/tests/components/st7123/common.yaml b/tests/components/st7123/common.yaml new file mode 100644 index 00000000000..b34eb669e0e --- /dev/null +++ b/tests/components/st7123/common.yaml @@ -0,0 +1,18 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: st7123_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: st7123_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: st7123 + i2c_id: i2c_bus + id: st7123_touchscreen + display: st7123_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/st7123/test.esp32-idf.yaml b/tests/components/st7123/test.esp32-idf.yaml new file mode 100644 index 00000000000..3bce86d9a3a --- /dev/null +++ b/tests/components/st7123/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + display_reset_pin: "10" + interrupt_pin: "20" + reset_pin: "21" + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From 41cf842d5d9ca377c6338760f91bcb4e7755080f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 2 Jul 2026 16:13:56 +0200 Subject: [PATCH 248/343] [zephyr][nrf52] Rebuild native build when config inputs change (#17318) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 20 ++++++++++--- esphome/components/zephyr/__init__.py | 41 +++++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 00271c97c70..64946e3cd1a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -58,7 +58,7 @@ from esphome.framework_helpers import ( get_project_link_flags, run_command_ok, ) -from esphome.helpers import write_file_if_changed +from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -697,7 +697,8 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False -def _generate_cmake_lists() -> None: +def _generate_cmake_lists() -> bool: + """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() @@ -732,7 +733,7 @@ def _generate_cmake_lists() -> None: ")", ] - write_file_if_changed( + return write_file_if_changed( CORE.relative_build_path("zephyr", "CMakeLists.txt"), "\n".join(lines) + "\n", ) @@ -751,12 +752,23 @@ def run_compile(args, config: ConfigType) -> bool: paths = get_build_paths() env = get_build_env() - _generate_cmake_lists() + cmake_lists_changed = _generate_cmake_lists() board = zephyr_data()[KEY_BOARD] build_dir = CORE.relative_pioenvs_path(CORE.name) source_dir = CORE.relative_build_path("zephyr") + # A missing CMake cache (dropped by zephyr's copy_files() on config + # change) or a changed CMakeLists.txt requires a pristine build: Zephyr + # caches Kconfig/devicetree state that survives a plain cmake re-run. + # West can't do the wipe — its pristine modes only recognize a build dir + # by reading ZEPHYR_BASE from the very cache that was dropped. + if ( + cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file() + ) and build_dir.is_dir(): + _LOGGER.info("Build inputs changed, cleaning %s", build_dir) + rmtree(build_dir) + west_cmd = [ str(paths["python_executable"]), "-m", diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index bd5f01aa3aa..cd077a142f0 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_BOARD, KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .const import ( CONF_CDC_ACM, @@ -203,7 +204,20 @@ def zephyr_add_user(key, value): user[key] += [value] -def copy_files(): +def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: + """Write content to path, or remove a stale file when content is empty. + + Returns True if the file changed on disk. + """ + if content: + return write_file_if_changed(path, content) + if path.is_file(): + path.unlink() + return True + return False + + +def copy_files() -> None: user = zephyr_data()[KEY_USER] if user: entries = " ".join( @@ -219,6 +233,8 @@ def copy_files(): """ ) + changed = False + for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items(): prj_conf = ( "\n".join( @@ -233,26 +249,25 @@ def copy_files(): else: path = CORE.relative_build_path("zephyr/prj.conf") - write_file_if_changed(CORE.relative_build_path(path), prj_conf) + changed |= write_file_if_changed(path, prj_conf) for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: path = CORE.relative_build_path(f"sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") - write_file_if_changed(path, content) + changed |= write_file_if_changed(path, content) for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items(): - copy_file_if_changed( + changed |= copy_file_if_changed( path, CORE.relative_build_path(filename), ) pm_static = "\n".join(str(item) for item in zephyr_data()[KEY_PM_STATIC]) - if pm_static: - write_file_if_changed( - CORE.relative_build_path("zephyr/pm_static.yml"), pm_static - ) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/pm_static.yml"), pm_static + ) kconfig = zephyr_data()[KEY_KCONFIG] if kconfig: @@ -267,4 +282,12 @@ def copy_files(): + "\n" + kconfig ) - write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/Kconfig"), kconfig + ) + + if changed: + # A configure-time input changed; drop the CMake cache so the build + # can't reuse stale configure results (the native sdk-nrf toolchain + # rebuilds pristine when the cache is missing). + clean_cmake_cache() From 65fc10d627f0ab8343694521562a4cd0edbdca4c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:26:34 -0400 Subject: [PATCH 249/343] [nrf52] Build PlatformIO libraries as Zephyr modules (sdk-nrf) (#17250) --- esphome/components/nrf52/__init__.py | 26 +++ esphome/components/zephyr/library.py | 180 ++++++++++++++++++++ esphome/espidf/component.py | 1 + esphome/platformio/library.py | 45 +++-- tests/unit_tests/test_espidf_component.py | 38 +++-- tests/unit_tests/test_platformio_library.py | 6 +- tests/unit_tests/test_zephyr_library.py | 117 +++++++++++++ 7 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 esphome/components/zephyr/library.py create mode 100644 tests/unit_tests/test_zephyr_library.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 64946e3cd1a..184d41e0f3d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -411,6 +411,17 @@ async def _dfu_to_code(dfu_config): def copy_files() -> None: """Copy files to the build directory.""" + # Library conversion to Zephyr modules is wired into the sdk-nrf + # CMakeLists only; the PlatformIO toolchain's forked platform package + # cannot compile external libraries at all, so the build would fail at + # link time anyway. Fail fast with a clear message instead. + if CORE.using_toolchain_platformio and CORE.platformio_libraries: + raise EsphomeError( + f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are " + "not supported on the nRF52 'platformio' toolchain; use toolchain " + "'sdk-nrf' to build them as Zephyr modules." + ) + if CORE.using_toolchain_platformio and ( zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT or zephyr_data()[KEY_BOARD] == "xiao_ble" @@ -702,11 +713,26 @@ def _generate_cmake_lists() -> bool: compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() + # Convert any PlatformIO libraries added via cg.add_library() into Zephyr + # modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set + # before find_package(Zephyr) so the modules are picked up). Only + # framework-agnostic libraries actually compile under Zephyr. + from esphome.components.zephyr.library import generate_zephyr_modules + + module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values())) + lines = [ "cmake_minimum_required(VERSION 3.20.0)", "", 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', "", + ] + + if module_dirs: + modules = ";".join(str(d).replace("\\", "/") for d in module_dirs) + lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""] + + lines += [ "find_package(Zephyr REQUIRED)", "", f"project({CORE.name})", diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py new file mode 100644 index 00000000000..7654e637009 --- /dev/null +++ b/esphome/components/zephyr/library.py @@ -0,0 +1,180 @@ +"""Zephyr backend for the shared PlatformIO library converter. + +For each PlatformIO library added via ``cg.add_library()``, emit a Zephyr +external module (``zephyr/module.yml`` + ``zephyr/CMakeLists.txt`` built with the +``zephyr_library*`` API) into the shared ``pio_components`` cache. The caller +wires the resulting module directories into the build via +``EXTRA_ZEPHYR_MODULES``; Zephyr then compiles each module and links it into the +final image. + +Only framework-agnostic libraries (plain C/C++ that doesn't depend on the Arduino +API) will actually compile under Zephyr — this converter shares the +fetch/parse/cache plumbing, not API compatibility. +""" + +from pathlib import Path + +from esphome import yaml_util +from esphome.core import EsphomeError, Library +from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + SRC_FILE_EXTENSIONS, + ConvertedLibrary, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) + +# Zephyr libraries declare frameworks rarely and the PIO ``platforms`` token for +# nRF is seldom present, so the platform check is disabled (None) and only the +# framework mismatch warning fires. +ZEPHYR_FRAMEWORK = "zephyr" + + +def _escape(p: PathType) -> str: + # In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF + # backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' -- + # preserves content, so it's safe for arbitrary build flags (e.g. a -D value + # containing a backslash) as well as Windows paths. + return f'"{str(p)}"'.replace("\\", "\\\\") + + +def generate_module_yml(component: ConvertedLibrary) -> str: + """Render the ``zephyr/module.yml`` manifest for a converted library.""" + return yaml_util.dump( + { + "name": component.get_require_name(), + "build": {"cmake": "zephyr"}, + } + ) + + +def generate_cmakelists_txt(component: ConvertedLibrary) -> str: + """Render the ``zephyr/CMakeLists.txt`` that builds a converted library. + + Sources/includes are emitted as absolute paths since the CMakeLists lives in + the library's ``zephyr/`` subdir while its sources sit alongside it. Include + dirs are published globally so the app (and sibling libraries) can include the + library's headers, mirroring ESP-IDF's public ``INCLUDE_DIRS``. + """ + build = component.data.get("build", {}) + + build_src_dir = build.get("srcDir") + if not build_src_dir: + for d in ["src", "Src", "."]: + if (component.path / Path(d)).is_dir(): + build_src_dir = d + break + + build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) + build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER)) + build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) + + src_files = collect_filtered_files( + component.path / Path(build_src_dir), build_src_filter + ) + src_files = sorted( + str(Path(p).resolve()) + for p in src_files + if Path(p).suffix in SRC_FILE_EXTENSIONS + ) + + include_dir_flags, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None + ) + link_directories, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None + ) + link_libraries, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None + ) + + include_dirs = [build_include_dir, build_src_dir, *include_dir_flags] + include_dirs = [ + str((component.path / Path(d)).resolve()) + for d in include_dirs + if (component.path / Path(d)).is_dir() + ] + + lines = [f"zephyr_library_named({component.get_require_name()})"] + if src_files: + lines += [ + "zephyr_library_sources(", + *[f" {_escape(p)}" for p in src_files], + ")", + ] + if include_dirs: + lines += [ + "zephyr_include_directories(", + *[f" {_escape(p)}" for p in include_dirs], + ")", + ] + if build_flags: + lines += [ + "zephyr_library_compile_options(", + *[f" {_escape(f)}" for f in build_flags], + ")", + ] + # Best-effort link wiring; most Zephyr-portable libraries don't need it. + link_flags = [f"-L{d}" for d in link_directories] + [ + f"-l{lib}" for lib in link_libraries + ] + if link_flags: + lines += [ + "zephyr_link_libraries(", + *[f" {_escape(f)}" for f in link_flags], + ")", + ] + + return "\n".join(lines) + "\n" + + +def _emit_zephyr_module(component: ConvertedLibrary) -> None: + zephyr_dir = component.path / "zephyr" + write_file_if_changed(zephyr_dir / "module.yml", generate_module_yml(component)) + write_file_if_changed( + zephyr_dir / "CMakeLists.txt", generate_cmakelists_txt(component) + ) + + +def generate_zephyr_modules(libraries: list[Library]) -> list[Path]: + """Convert ``libraries`` to Zephyr modules and return all module directories. + + The returned list includes transitive dependencies (each converted library is + its own module). Every directory should be added to ``EXTRA_ZEPHYR_MODULES``; + Zephyr links all module libraries into the image, so cross-library symbols + resolve without explicit dependency declarations. + + Raises ``EsphomeError`` if two libraries resolve to the same Zephyr module + name -- each module's CMakeLists calls ``zephyr_library_named()``, so a + duplicate would otherwise fail the build with a CMake "target already exists". + The converter already warns when a library is referenced under inconsistent + specs (bare ``name`` vs ``owner/name``, git vs registry); this turns that into + an actionable error at the Zephyr boundary where it is fatal. + """ + module_dirs: list[Path] = [] + by_name: dict[str, Path] = {} + + def emit(component: ConvertedLibrary) -> None: + name = component.get_require_name() + if name in by_name: + raise EsphomeError( + f"Two libraries resolve to the same Zephyr module '{name}' " + f"({by_name[name]} and {component.path}). Reference the library " + f"consistently (e.g. always as 'owner/name') so it resolves once." + ) + by_name[name] = component.path + _emit_zephyr_module(component) + module_dirs.append(component.path) + + backend = LibraryBackend( + platform=None, framework=ZEPHYR_FRAMEWORK, emit=emit, cache_key="zephyr" + ) + convert_libraries(libraries, backend) + return module_dirs diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 5029e014a45..e9ec170a5e2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -264,5 +264,6 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: platform=ESP32_PLATFORM, framework=_idf_framework(), emit=_emit_idf_component, + cache_key="idf", ) return convert_libraries(libraries, backend) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index c2d783ecbe9..291bedb5cd7 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -68,7 +68,9 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: raise NotImplementedError @@ -76,8 +78,14 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + # Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so + # the build files each backend writes into the library dir can't collide. base_dir = Path(CORE.data_dir) / DOMAIN + if namespace: + base_dir = base_dir / namespace h = hashlib.new("sha256") h.update(self.url.encode()) if salt: @@ -113,12 +121,19 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + domain = DOMAIN + if namespace: + domain = f"{domain}/{namespace}" + if salt: + domain = f"{domain}/{salt}" path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, + domain=domain, submodules=[], subpath=Path(dir_suffix), ) @@ -167,16 +182,16 @@ class ConvertedLibrary: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False, salt: str = ""): + def download(self, force: bool = False, salt: str = "", namespace: str = ""): """Fetch the library into the shared cache and record its ``path``. The cache directory is named after the sanitized library name; backends rely on that name to identify the unit they build (e.g. ESP-IDF uses the directory name as the component name, replacing ``/`` with ``__`` via - ``get_require_name``). + ``get_require_name``). ``namespace`` keeps each backend's cache separate. """ self.path = self.source.download( - self.get_sanitized_name(), force=force, salt=salt + self.get_sanitized_name(), force=force, salt=salt, namespace=namespace ) @@ -188,11 +203,15 @@ class LibraryBackend: ``emit`` writes the toolchain-specific build files into a resolved library's ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a Zephyr ``module.yml`` + ``CMakeLists.txt``). + ``cache_key`` namespaces the download cache (``pio_components//``) + so the differing build files two backends emit into a library dir never + collide when the same config dir hosts both an ESP-IDF and a Zephyr build. """ - platform: str + platform: str | None framework: str emit: Callable[["ConvertedLibrary"], None] + cache_key: str def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -306,7 +325,7 @@ def split_list_by_condition( return matched, non_matched -def check_library_data(data: dict, platform: str, framework: str): +def check_library_data(data: dict, platform: str | None, framework: str): """ Check whether a library manifest is compatible with the target toolchain. @@ -319,7 +338,9 @@ def check_library_data(data: dict, platform: str, framework: str): Args: data: PIO library manifest dict being processed. platform: The PlatformIO platform token the build targets (e.g. - ``espressif32``). + ``espressif32``). ``None`` skips the platform check entirely — useful + for targets (e.g. Zephyr) where PIO manifests rarely declare the + platform yet portable libraries still build. framework: The active framework name (e.g. ``espidf``, ``arduino``, ``zephyr``) the manifest is expected to declare. @@ -332,7 +353,7 @@ def check_library_data(data: dict, platform: str, framework: str): platforms = ensure_list(platforms) # Check if library supports the target platform - valid_platforms = "*" in platforms or platform in platforms + valid_platforms = platform is None or "*" in platforms or platform in platforms if not valid_platforms: raise InvalidLibrary(f"Unsupported library platforms: {platforms}") @@ -613,7 +634,7 @@ def convert_libraries( component = ConvertedLibrary( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download(salt=salt) + component.download(salt=salt, namespace=backend.cache_key) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index d43a1d52769..a50024b8e96 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -481,7 +481,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -543,7 +543,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( download_salts: list[str] = [] - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): download_salts.append(salt) self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) @@ -597,7 +597,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -654,7 +654,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -691,7 +691,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -733,7 +733,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -766,7 +766,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -804,7 +804,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -847,6 +847,13 @@ def test_url_source_salt_changes_cache_path( assert source.download("lib") == expected[""] assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + # A backend namespace adds a pio_components// subdir. + digest = hashlib.sha256(url.encode()).hexdigest()[:8] + ns_expected = base / "idf" / digest / "lib" + ns_expected.mkdir(parents=True) + (ns_expected / ".esphome_extracted").touch() + assert source.download("lib", namespace="idf") == ns_expected + def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: """The salt becomes a subdirectory of the git clone domain.""" @@ -863,7 +870,14 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") source.download("noise-c") source.download("noise-c", salt="abcd1234") - assert domains == ["pio_components", "pio_components/abcd1234"] + source.download("noise-c", namespace="idf") + source.download("noise-c", namespace="zephyr", salt="abcd1234") + assert domains == [ + "pio_components", + "pio_components/abcd1234", + "pio_components/idf", + "pio_components/zephyr/abcd1234", + ] def test_idf_component_download_passes_salt() -> None: @@ -873,7 +887,9 @@ def test_idf_component_download_passes_salt() -> None: source.download.return_value = Path("/converted/owner/name") c = IDFComponent("owner/name", "1.0", source=source) - c.download(force=True, salt="abcd1234") + c.download(force=True, salt="abcd1234", namespace="idf") - source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + source.download.assert_called_once_with( + "owner/name", force=True, salt="abcd1234", namespace="idf" + ) assert c.path == Path("/converted/owner/name") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 55bc396c25a..03360eab37c 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -26,7 +26,9 @@ from esphome.platformio.library import ( def _backend(emit=lambda component: None) -> LibraryBackend: - return LibraryBackend(platform="espressif32", framework="espidf", emit=emit) + return LibraryBackend( + platform="espressif32", framework="espidf", emit=emit, cache_key="idf" + ) def test_check_library_data_accepts_wildcards(): @@ -134,7 +136,7 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") self.path.mkdir(parents=True, exist_ok=True) if self.name in properties: diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py new file mode 100644 index 00000000000..0ba3577fa71 --- /dev/null +++ b/tests/unit_tests/test_zephyr_library.py @@ -0,0 +1,117 @@ +"""Tests for the Zephyr backend of the shared PlatformIO library converter.""" + +from pathlib import Path + +import pytest + +import esphome.components.zephyr.library as zlib +from esphome.components.zephyr.library import ( + generate_cmakelists_txt, + generate_module_yml, + generate_zephyr_modules, +) +from esphome.core import EsphomeError, Library +from esphome.platformio.library import ConvertedLibrary, URLSource + + +def _make_component(path: Path, name: str = "mylib") -> ConvertedLibrary: + c = ConvertedLibrary(name, "1.0", source=URLSource("http://dummy")) + c.path = path + return c + + +def test_generate_module_yml_uses_sanitized_name(): + c = ConvertedLibrary("owner/My Lib", "1.0", source=URLSource("http://dummy")) + out = generate_module_yml(c) + # "/" -> "__" and " " -> "_" so it's a valid Zephyr module name. + assert "name: owner__My_Lib" in out + assert "cmake: zephyr" in out + + +def test_generate_cmakelists_txt_basic(tmp_path): + c = _make_component(tmp_path) + src = tmp_path / "src" + src.mkdir() + (src / "main.c").write_text("int main() {}") + c.data = {} + + out = generate_cmakelists_txt(c) + + assert "zephyr_library_named(mylib)" in out + assert "zephyr_library_sources(" in out + # Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir), + # backslash-escaped for CMake (matching the output on Windows). + assert str((src / "main.c").resolve()).replace("\\", "\\\\") in out + + +def test_generate_cmakelists_txt_flags_and_includes(tmp_path): + c = _make_component(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.c").write_text("") + (tmp_path / "include").mkdir() + c.data = {"build": {"flags": ["-Iinclude", "-DFOO", "-Wall", "-Llibdir", "-lm"]}} + + out = generate_cmakelists_txt(c) + + assert "zephyr_include_directories(" in out + assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out + assert "zephyr_library_compile_options(" in out + assert "-DFOO" in out + assert "-Wall" in out + assert "zephyr_link_libraries(" in out + assert "-Llibdir" in out + assert "-lm" in out + + +def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch): + # Two converted libraries: one top-level, one transitive dependency. The + # converter calls backend.emit for both; generate_zephyr_modules must return + # *all* module dirs (not just top-level) so every module is discoverable. + top = _make_component(tmp_path / "top", "top") + (top.path / "src").mkdir(parents=True) + (top.path / "src" / "t.c").write_text("") + dep = _make_component(tmp_path / "dep", "dep") + (dep.path / "src").mkdir(parents=True) + (dep.path / "src" / "d.c").write_text("") + + captured = {} + + def fake_convert(libraries, backend): + captured["platform"] = backend.platform + captured["framework"] = backend.framework + backend.emit(top) + backend.emit(dep) + return [top] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + dirs = generate_zephyr_modules([Library("top", "1.0", None)]) + + assert dirs == [top.path, dep.path] + # Platform check disabled for Zephyr; framework declared as zephyr. + assert captured["platform"] is None + assert captured["framework"] == "zephyr" + for comp in (top, dep): + assert (comp.path / "zephyr" / "module.yml").is_file() + assert (comp.path / "zephyr" / "CMakeLists.txt").is_file() + + +def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch): + # The same library referenced under inconsistent specs (e.g. bare vs + # owner-qualified, or git vs registry) resolves to two components with the + # same Zephyr module name, which would collide in zephyr_library_named(). + a = _make_component(tmp_path / "a", "esphome/noise-c") + a.path.mkdir(parents=True) + b = _make_component(tmp_path / "b", "esphome/noise-c") + b.path.mkdir(parents=True) + assert a.get_require_name() == b.get_require_name() + + def fake_convert(libraries, backend): + backend.emit(a) + backend.emit(b) + return [a] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + with pytest.raises(EsphomeError, match="same Zephyr module"): + generate_zephyr_modules([Library("esphome/noise-c", "1.0", None)]) From 648f5e1b068201c64d5382dc9b035395e997226b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:42:59 -0400 Subject: [PATCH 250/343] [nrf52] Install native sdk-nrf into a machine-global cache dir (#17353) --- docker/docker_entrypoint.sh | 3 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 3 +- esphome/components/nrf52/framework.py | 21 ++++-- esphome/espidf/framework.py | 16 ++--- esphome/writer.py | 21 ++++-- tests/unit_tests/test_espidf_framework.py | 28 ++++---- tests/unit_tests/test_nrf52_framework.py | 61 ++++++++++++++++- tests/unit_tests/test_writer.py | 68 +++++++++++++++++-- 8 files changed, 180 insertions(+), 41 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 598b553c082..c88a78f97e1 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,9 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native ESP-IDF install on the persistent cache root, not the +# Keep the native toolchain installs on the persistent cache root, not the # container's ephemeral user cache dir (re-downloaded on every restart). export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" +export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf" # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index f50de659b90..20fada5f130 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,9 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native ESP-IDF install on the persistent /data volume, not the +# Keep the native toolchain installs on the persistent /data volume, not the # container's ephemeral user cache dir (wiped on every add-on update/restart). export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf +export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7aec6b088ec..7cb1164482a 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -4,6 +4,8 @@ from pathlib import Path import platform import tempfile +import platformdirs + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -15,6 +17,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) +from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) @@ -38,20 +41,28 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( ) -def _get_tools_path() -> Path: - return CORE.data_dir / "sdk-nrf" +def get_sdk_nrf_tools_path() -> Path: + # A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("") + # resolves to the CWD, which clean-all would then delete. + if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip(): + path = Path(prefix).expanduser() + else: + # Machine-global (OS user cache dir) so all projects share one install; + # see espidf.framework.get_idf_tools_path for the location rationale. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + return path.resolve() def _get_python_env_path(version: str) -> Path: - return _get_tools_path() / "penvs" / version + return get_sdk_nrf_tools_path() / "penvs" / version def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / version + return get_sdk_nrf_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / version + return get_sdk_nrf_tools_path() / "toolchains" / version _SITECUSTOMIZE = """\ diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 25283e3c99d..810a63476f6 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -75,7 +75,7 @@ ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( ) -def _get_idf_tools_path() -> Path: +def get_idf_tools_path() -> Path: """ Get the path to the ESP-IDF tools directory. @@ -141,7 +141,7 @@ def _check_windows_path_length() -> None: """ if platform.system() != "Windows" or _windows_long_paths_enabled(): return - tools_path = str(_get_idf_tools_path()) + tools_path = str(get_idf_tools_path()) projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN if projected <= _WINDOWS_MAX_PATH: return @@ -180,7 +180,7 @@ def _get_framework_path(version: str) -> Path: Returns: Path object pointing to the framework directory """ - return _get_idf_tools_path() / "frameworks" / f"{version}" + return get_idf_tools_path() / "frameworks" / f"{version}" def _get_python_env_path(version: str) -> Path: @@ -193,7 +193,7 @@ def _get_python_env_path(version: str) -> Path: Returns: Path object pointing to the Python environment directory """ - return _get_idf_tools_path() / "penvs" / f"{version}" + return get_idf_tools_path() / "penvs" / f"{version}" def _check_stamp(file: PathType, data: dict[str, str]) -> bool: @@ -707,7 +707,7 @@ def _check_esp_idf_python_env_install( esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( - _get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" + get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" ) _LOGGER.debug("ESP-IDF version %s", esp_idf_version) @@ -798,7 +798,7 @@ def check_esp_idf_install( _check_windows_path_length() env = {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" targets = targets or ESPHOME_IDF_DEFAULT_TARGETS @@ -867,7 +867,7 @@ def _ccache_env() -> dict[str, str]: defaults = { "IDF_CCACHE_ENABLE": "1", - "CCACHE_DIR": str(_get_idf_tools_path() / "ccache"), + "CCACHE_DIR": str(get_idf_tools_path() / "ccache"), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), @@ -894,7 +894,7 @@ def get_framework_env( """ # 1. Initialize base environment with extra ESP-IDF environment variables env = env.copy() if env else {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" # 2. Get existing PATH from env or os.environ diff --git a/esphome/writer.py b/esphome/writer.py index 52f2d169b35..b7eeec916d8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,14 +653,21 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) - # The native ESP-IDF install lives in a machine-global cache dir, outside - # any .esphome data dir, so the per-config loop above won't reach it. - from esphome.espidf.framework import _get_idf_tools_path + # The native toolchain installs live in a machine-global cache dir that + # the per-config loop above can't reach. Wipe the default cache root + # (also catches leftovers from older install layouts), then the resolved + # install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI) + # that live outside it. + import platformdirs - idf_install_path = _get_idf_tools_path() - if idf_install_path.is_dir(): - _LOGGER.info("Deleting %s", idf_install_path) - rmtree(idf_install_path) + from esphome.components.nrf52.framework import get_sdk_nrf_tools_path + from esphome.espidf.framework import get_idf_tools_path + + cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve() + for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()): + if install_path.is_dir(): + _LOGGER.info("Deleting %s", install_path) + rmtree(install_path) # Clean PlatformIO project files try: diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index f3e160925a8..c5d9ddbaf1f 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -21,7 +21,6 @@ from esphome.espidf.framework import ( _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, - _get_idf_tools_path, _get_idf_version, _get_python_env_path, _get_python_version, @@ -32,6 +31,7 @@ from esphome.espidf.framework import ( _write_stamp, check_esp_idf_install, get_framework_env, + get_idf_tools_path, ) from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path @@ -639,7 +639,7 @@ def test_write_stamp_writes_json(tmp_path: Path) -> None: def test_get_framework_env_with_python_env(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -664,7 +664,7 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -687,7 +687,7 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): return ( patch("esphome.espidf.framework.shutil.which", return_value=which), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch( @@ -761,7 +761,7 @@ def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# _check_stamp / _write_idf_version_txt / get_idf_tools_path # --------------------------------------------------------------------------- @@ -798,14 +798,14 @@ def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" -def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: +def testget_idf_tools_path_env_override(tmp_path: Path) -> None: override = str(tmp_path / "custom-idf") with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): - assert _get_idf_tools_path() == Path(override) + assert get_idf_tools_path() == Path(override) @pytest.mark.parametrize("value", ["", " "]) -def test_get_idf_tools_path_blank_env_falls_back_to_default( +def testget_idf_tools_path_blank_env_falls_back_to_default( value: str, monkeypatch: pytest.MonkeyPatch ) -> None: """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. @@ -819,10 +819,10 @@ def test_get_idf_tools_path_blank_env_falls_back_to_default( expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() - assert _get_idf_tools_path() == expected + assert get_idf_tools_path() == expected -def test_get_idf_tools_path_default_uses_user_cache( +def testget_idf_tools_path_default_uses_user_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: """Without the env override the install root is the machine-global OS user @@ -833,7 +833,7 @@ def test_get_idf_tools_path_default_uses_user_cache( expected = ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" ).resolve() - assert _get_idf_tools_path() == expected + assert get_idf_tools_path() == expected def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: @@ -908,7 +908,7 @@ def test_check_windows_path_length_noop_when_long_paths_enabled( patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=True ), - patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock, caplog.at_level(logging.WARNING), ): _check_windows_path_length() @@ -925,7 +925,7 @@ def test_check_windows_path_length_short_path_silent( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_SHORT_IDF_PATH, ), caplog.at_level(logging.WARNING), @@ -943,7 +943,7 @@ def test_check_windows_path_length_long_path_warns( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_LONG_IDF_PATH, ), caplog.at_level(logging.WARNING), diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 04c712f0b73..2b3d1f6db8a 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -10,12 +10,28 @@ from esphome.components.nrf52.framework import ( _TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +@pytest.fixture(autouse=True) +def _isolate_sdk_nrf_install_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pin the sdk-nrf install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the install dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the + env themselves. + """ + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install")) + + @pytest.mark.parametrize( ("system", "machine", "expected"), [ @@ -52,7 +68,7 @@ _TEST_SDK_VERSION = "2.9.0" def nrf52_dirs(setup_core: Path) -> SimpleNamespace: """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} - tools = CORE.data_dir / "sdk-nrf" + tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION @@ -226,3 +242,46 @@ class TestCheckAndInstall: assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" + + +# --------------------------------------------------------------------------- +# get_sdk_nrf_tools_path tests +# --------------------------------------------------------------------------- + + +def testget_tools_path_env_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + override = tmp_path / "custom" / "sdk-nrf" + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override)) + assert get_sdk_nrf_tools_path() == override.resolve() + + +@pytest.mark.parametrize("value", ["", " "]) +def testget_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could + then delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected + + +def testget_tools_path_default_is_global_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import platformdirs + + monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 18d08e7cb1d..07f334d350d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -68,12 +68,16 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. - Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the - same reason: ``clean_all`` removes the now machine-global ESP-IDF - install, which otherwise defaults to the real ``~/.cache/esphome``. + Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to + nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the + same reason: ``clean_all`` removes the machine-global toolchain installs + and their default cache root, which otherwise resolve to the real + ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" + sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent" + cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" @@ -83,7 +87,14 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: "platformio.project.config.ProjectConfig.get_instance", return_value=mock_cfg, ), - patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}), + patch.dict( + "os.environ", + { + "ESPHOME_ESP_IDF_PREFIX": str(idf_root), + "ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root), + }, + ), + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), ): yield @@ -1022,6 +1033,55 @@ def test_clean_all_removes_global_idf_install( assert str(idf_install.resolve()) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_sdk_nrf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native sdk-nrf install dir.""" + sdk_nrf_install = tmp_path / "sdk_nrf_install" + (sdk_nrf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(sdk_nrf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not sdk_nrf_install.exists() + assert str(sdk_nrf_install.resolve()) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_default_cache_root( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the default cache root (stale/orphaned installs).""" + cache_root = tmp_path / "cache_root" + (cache_root / "some-old-toolchain").mkdir(parents=True) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with ( + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), + caplog.at_level("INFO"), + ): + clean_all([str(config_dir)]) + + assert not cache_root.exists() + assert str(cache_root.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, From 4f0968f1df0b57751133f4aab4f7d54aea85b537 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:35:46 -0400 Subject: [PATCH 251/343] Bump github/codeql-action/analyze from 4.36.2 to 4.36.3 (#17363) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5a448c40031..6ca3e065cc7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:${{matrix.language}}" From bef6773281f2fa0e0047df26f1eea597791945fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:01 -0400 Subject: [PATCH 252/343] Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#17362) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6ca3e065cc7..610e6ed020a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 0b48ca00278f692dbc5236743f73aa995807557e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:33 -0400 Subject: [PATCH 253/343] Bump the docker-actions group with 2 updates (#17361) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 8 ++++---- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index d6ad28dffe4..07a792df085 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Determine tag and whether to push id: tag @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -151,10 +151,10 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20a77b152d8..d00c6523c72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,15 +99,15 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.actor }} From f447c88b4c032a3461ba3f7ee93f55263f605cad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:37:12 -0400 Subject: [PATCH 254/343] Bump docker/build-push-action from 7.2.0 to 7.3.0 in /.github/actions/build-image (#17336) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 494c0cebe80..133d7ca8d82 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 5417a16f9dc6d67f175ddbc5a5c8b02fb8674fce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:37:22 -0400 Subject: [PATCH 255/343] Update argcomplete requirement from >=3.6.3 to >=3.7.0 (#17334) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index baa8b5efd20..95388f278f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,4 +29,4 @@ platformdirs==4.10.0 # native esp-idf toolchain global cache dir pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.6.3 +argcomplete>=3.7.0 From 9f589ec4fcad48e61e2d6ccbf74889e089a6640e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:47:12 -0400 Subject: [PATCH 256/343] [api] Register homeassistant.action with synchronous=False to fix stale trigger args in response callbacks (#17367) --- esphome/components/api/__init__.py | 9 +++- tests/component_tests/api/__init__.py | 0 .../api/test_homeassistant_action.py | 28 ++++++++++++ .../api/test_homeassistant_action.yaml | 43 +++++++++++++++++++ tests/components/api/common-base.yaml | 28 ++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/api/__init__.py create mode 100644 tests/component_tests/api/test_homeassistant_action.py create mode 100644 tests/component_tests/api/test_homeassistant_action.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0f5cd936f54..1146b435968 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -540,17 +540,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +# synchronous=False: when on_success/on_error is configured, play() stores the +# trigger args until the HomeassistantActionResponse arrives, so non-owning args +# (StringRef into the API receive buffer) must not be used. @automation.register_action( "homeassistant.action", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) @automation.register_action( "homeassistant.service", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) async def homeassistant_service_to_code( config: ConfigType, @@ -644,6 +647,8 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( ) +# synchronous=True is safe here: the event schema has no on_success/on_error, +# so play() never stores the trigger args. @automation.register_action( "homeassistant.event", HomeAssistantServiceCallAction, diff --git a/tests/component_tests/api/__init__.py b/tests/component_tests/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/api/test_homeassistant_action.py b/tests/component_tests/api/test_homeassistant_action.py new file mode 100644 index 00000000000..611353e7c56 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.py @@ -0,0 +1,28 @@ +"""Tests for arg-type selection of api user-defined services with homeassistant.action.""" + +CONFIG = "tests/component_tests/api/test_homeassistant_action.yaml" + + +def test_synchronous_chain_keeps_zero_copy_args(generate_main): + """A chain of synchronous actions keeps the non-owning StringRef arg type.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("zero_copy_args", {"message"})' in main_cpp + ) + + +def test_response_callback_args_are_owning(generate_main): + """homeassistant.action with on_success/on_error stores the trigger args + until the HomeassistantActionResponse arrives, so string args must fall + back to owning std::string; StringRef would point into the connection's + receive buffer, which is reused before the response arrives.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("response_args", {"message"})' in main_cpp + ) + assert "api::HomeAssistantServiceCallAction" in main_cpp + assert "api::HomeAssistantServiceCallAction" not in main_cpp diff --git a/tests/component_tests/api/test_homeassistant_action.yaml b/tests/component_tests/api/test_homeassistant_action.yaml new file mode 100644 index 00000000000..4561494c9ee --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.yaml @@ -0,0 +1,43 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + actions: + # Chain of synchronous actions that never store the args: + # keeps the zero-copy StringRef arg type. + - action: zero_copy_args + variables: + message: string + then: + - logger.log: + format: "%s" + args: [message.c_str()] + # homeassistant.action with on_success/on_error stores the trigger args + # until the action response arrives, so the codegen must fall back to + # owning std::string args (StringRef would dangle once the receive + # buffer is reused). + - action: response_args + variables: + message: string + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda return message; + on_success: + - logger.log: + format: "sent %s" + args: [message.c_str()] + on_error: + - logger.log: + format: "failed (%s): %s" + args: [error.c_str(), message.c_str()] diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 060254990df..d7470ee4b3b 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -109,6 +109,34 @@ api: - name.c_str() - int_arr.size() - string_arr.size() + # Test string + array args used by homeassistant.action's deferred + # on_success/on_error response callback. homeassistant.action registers + # synchronous=False, so the api codegen must fall back to owning + # std::string / std::vector args here: the non-owning defaults would + # dangle once rx_buf_ is reused before the response arrives, and the + # non-copyable FixedVector would fail to compile when captured into + # the response callback. + - action: action_response_args + variables: + name: string + int_arr: int[] + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda 'return name;' + on_success: + - logger.log: + format: "Notified %s (%u ints)" + args: + - name.c_str() + - int_arr.size() + on_error: + - logger.log: + format: "Notify failed (%s): %s" + args: + - error.c_str() + - name.c_str() # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From 0725157bf50521298866ee7e7a22c8e222f51be9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:08 -0500 Subject: [PATCH 257/343] Bump bundled esphome-device-builder to 1.0.26 (#17369) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 543f17db56e..7d56b040415 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.25 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 RUN \ platformio settings set enable_telemetry No \ From 5fe36a45edf7309a65260fb181ea3c845705a15c Mon Sep 17 00:00:00 2001 From: Joseph Spiros Date: Thu, 2 Jul 2026 19:48:55 -0400 Subject: [PATCH 258/343] [core] Skip MAC-suffix mDNS discovery for non-mDNS addresses (#16874) --- esphome/__main__.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 1767d3b7cac..2cc904ff4b4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -225,8 +225,9 @@ def _discover_mac_suffix_devices() -> list[str] | None: Returns: - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, - mDNS disabled, or ``CORE.address`` is already an IP). Callers should - then fall back to whatever default OTA address they normally use. + mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address). + Callers should then fall back to whatever default OTA address they + normally use. - ``[]`` when discovery ran but found nothing. Callers should NOT fall back to the base name: with ``name_add_mac_suffix`` enabled, the base name by definition doesn't exist on the network. @@ -236,7 +237,7 @@ def _discover_mac_suffix_devices() -> list[str] | None: ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we already have without opening a second Zeroconf client. """ - if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()): return None from esphome.zeroconf import discover_mdns_devices @@ -503,17 +504,22 @@ def has_mdns() -> bool: def has_non_ip_address() -> bool: - """Check if CORE.address is set and is not an IP address.""" + """Check if ``CORE.address`` is set and is not an IP address.""" return CORE.address is not None and not is_ip_address(CORE.address) +def has_mdns_address() -> bool: + """Check if ``CORE.address`` is a ``.local`` mDNS hostname.""" + return CORE.address is not None and CORE.address.endswith(".local") + + def has_ip_address() -> bool: - """Check if CORE.address is a valid IP address.""" + """Check if ``CORE.address`` is a valid IP address.""" return CORE.address is not None and is_ip_address(CORE.address) def has_resolvable_address() -> bool: - """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address).""" + """Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address).""" # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver if CORE.address is None: @@ -532,7 +538,7 @@ def has_resolvable_address() -> bool: return True # .local mDNS hostnames are only resolvable if mDNS is enabled - return not CORE.address.endswith(".local") + return not has_mdns_address() def has_name_add_mac_suffix() -> bool: From c3233739c591d321cb7277ee6665beb8a5a85967 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Fri, 3 Jul 2026 15:13:21 +0530 Subject: [PATCH 259/343] [zephyr] Implement GPIO interrupts (ISRInternalGPIOPin) (#17077) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: tomaszduda23 --- .../components/gpio/binary_sensor/__init__.py | 3 +- esphome/components/zephyr/gpio.cpp | 82 ++++++++++++++++++- esphome/components/zephyr/gpio.h | 15 ++++ .../components/gpio/test.nrf52-adafruit.yaml | 24 ++++++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index f14a920c24b..2f1aa936a3c 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -39,7 +39,6 @@ CONFIG_SCHEMA = ( # due to hardware limitations or lack of reliable interrupt support. This ensures # stable operation on these platforms. Future maintainers should verify platform # capabilities before changing this default behavior. - # nrf52 has no gpio interrupts implemented yet cv.SplitDefault( CONF_USE_INTERRUPT, bk72xx=False, @@ -47,7 +46,7 @@ CONFIG_SCHEMA = ( esp8266=True, host=True, ln882x=False, - nrf52=False, + nrf52=True, rp2040=True, rtl87xx=False, ): cv.boolean, diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1d5b0f282b3..1e4201d8f5c 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_ZEPHYR #include "gpio.h" #include +#include #include "esphome/core/log.h" namespace esphome { @@ -33,20 +34,80 @@ static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) return ret; } +// ESPHome's InterruptType is expressed in logical levels, but the pin is configured active-high in Zephyr (inversion is +// applied in software by digital_read()/digital_write(), see the `!= inverted_` convention below). So when the pin is +// inverted we must swap the physical edge/level the interrupt arms on: a logical rising edge is a physical falling +// edge, etc. GPIO_INT_EDGE_BOTH is symmetric and needs no swap. +static gpio_flags_t interrupt_type_to_flags(gpio::InterruptType type, bool inverted) { + switch (type) { + case gpio::INTERRUPT_RISING_EDGE: + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; + case gpio::INTERRUPT_FALLING_EDGE: + return inverted ? GPIO_INT_EDGE_RISING : GPIO_INT_EDGE_FALLING; + case gpio::INTERRUPT_ANY_EDGE: + return GPIO_INT_EDGE_BOTH; + case gpio::INTERRUPT_LOW_LEVEL: + return inverted ? GPIO_INT_LEVEL_HIGH : GPIO_INT_LEVEL_LOW; + case gpio::INTERRUPT_HIGH_LEVEL: + return inverted ? GPIO_INT_LEVEL_LOW : GPIO_INT_LEVEL_HIGH; + } + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; +} + +// Zephyr calls this with a pointer to the gpio_callback the interrupt fired on. +// Recover the owning ZephyrGPIOInterrupt and dispatch to the ESPHome ISR. +static void gpio_interrupt_handler(const device * /*dev*/, gpio_callback *cb, uint32_t /*pins*/) { + auto *interrupt = CONTAINER_OF(cb, ZephyrGPIOInterrupt, callback); + if (interrupt->func != nullptr) { + interrupt->func(interrupt->arg); + } +} + struct ISRPinArg { + const device *gpio; uint8_t pin; + uint8_t gpio_size; bool inverted; }; ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) + arg->gpio = this->gpio_; arg->pin = this->pin_; + arg->gpio_size = this->gpio_size_; arg->inverted = this->inverted_; return ISRInternalGPIOPin((void *) arg); } void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { - // TODO + if (!device_is_ready(this->gpio_)) { + ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); + return; + } + + // Drop any interrupt previously attached to this pin before re-registering. + this->detach_interrupt(); + + this->interrupt_.func = func; + this->interrupt_.arg = arg; + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_init_callback(&this->interrupt_.callback, gpio_interrupt_handler, BIT(port_pin)); + + int ret = gpio_add_callback(this->gpio_, &this->interrupt_.callback); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_add_callback failed for pin %u: %d", this->pin_, ret); + return; + } + + ret = gpio_pin_interrupt_configure(this->gpio_, port_pin, interrupt_type_to_flags(type, this->inverted_)); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_pin_interrupt_configure failed for pin %u: %d", this->pin_, ret); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + return; + } + + ESP_LOGD(TAG, "Interrupt attached to pin %u (type=%d)", this->pin_, (int) type); } void ZephyrGPIOPin::setup() { @@ -88,15 +149,28 @@ void ZephyrGPIOPin::digital_write(bool value) { } gpio_pin_set(this->gpio_, this->pin_ % this->gpio_size_, value != this->inverted_ ? 1 : 0); } + void ZephyrGPIOPin::detach_interrupt() const { - // TODO + if (this->gpio_ == nullptr) { + return; + } + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_pin_interrupt_configure(this->gpio_, port_pin, GPIO_INT_DISABLE); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + + this->interrupt_.func = nullptr; + this->interrupt_.arg = nullptr; } } // namespace zephyr bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { - // TODO - return false; + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return false; + } + return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } } // namespace esphome diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 907fbe9f9cc..19d68cfb2be 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -3,8 +3,19 @@ #ifdef USE_ZEPHYR #include "esphome/core/hal.h" #include +#include namespace esphome::zephyr { +// Bundles the Zephyr gpio_callback together with the ESPHome ISR function and +// argument. Keeping them in one POD struct lets the static handler recover the +// owning data straight from the callback pointer via CONTAINER_OF, so no global +// pin->instance lookup table is needed. +struct ZephyrGPIOInterrupt { + struct gpio_callback callback; + void (*func)(void *){nullptr}; + void *arg{nullptr}; +}; + class ZephyrGPIOPin : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { @@ -36,6 +47,10 @@ class ZephyrGPIOPin : public InternalGPIOPin { uint8_t gpio_size_{}; bool inverted_{}; bool value_{false}; + + // attach_interrupt()/detach_interrupt() are const (matching the base class), so + // the interrupt state they manage has to be mutable. + mutable ZephyrGPIOInterrupt interrupt_{}; }; } // namespace esphome::zephyr diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index fb3f368e034..d0347365248 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -1,7 +1,31 @@ +# P0.2, P0.4 and P0.5 all live on the same Zephyr port device (gpio0) and each +# attaches its own interrupt. This locks in shared-port behavior: every pin owns +# a separate gpio_callback initialized with its own BIT(pin) mask, so Zephyr +# dispatches to each pin independently even though the port device is shared. binary_sensor: - platform: gpio pin: 2 id: gpio_binary_sensor + use_interrupt: true + interrupt_type: ANY + + # Inverted pin with an edge-specific interrupt: exercises the inversion-aware + # interrupt-arming path (logical RISING must arm on the physical falling edge). + - platform: gpio + pin: + number: P0.4 + inverted: true + id: gpio_binary_sensor_inverted + use_interrupt: true + interrupt_type: RISING + + # Second non-inverted interrupt on the same port (gpio0) as P0.2 above: verifies + # multiple pins sharing one port device each get their own callback/pin_mask. + - platform: gpio + pin: P0.5 + id: gpio_binary_sensor_shared_port + use_interrupt: true + interrupt_type: FALLING output: - platform: gpio From 711d8bb0ade3d32361e9e129cfaa99347b0a1675 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:33:00 -0400 Subject: [PATCH 260/343] Synchronise Device Classes from Home Assistant (#17372) Co-authored-by: esphomebot --- esphome/components/number/__init__.py | 2 ++ esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 3 files changed, 5 insertions(+) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ee2d53c65a4..bcc609de650 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -59,6 +59,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -131,6 +132,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5a2ebf03c0d..da8a540d8dc 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -87,6 +87,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -166,6 +167,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, diff --git a/esphome/const.py b/esphome/const.py index 5fa6f00b59e..331eb5011d8 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1351,6 +1351,7 @@ DEVICE_CLASS_PRECIPITATION_INTENSITY = "precipitation_intensity" DEVICE_CLASS_PRESENCE = "presence" DEVICE_CLASS_PRESSURE = "pressure" DEVICE_CLASS_PROBLEM = "problem" +DEVICE_CLASS_RADON = "radon" DEVICE_CLASS_REACTIVE_ENERGY = "reactive_energy" DEVICE_CLASS_REACTIVE_POWER = "reactive_power" DEVICE_CLASS_RESTART = "restart" From c456fc98ab59f6fbbafecb53c41156c782aac545 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:31:21 -0400 Subject: [PATCH 261/343] Bump bundled esphome-device-builder to 1.0.27 (#17370) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d56b040415..9dec23db1bb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.26 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 RUN \ platformio settings set enable_telemetry No \ From ea14a93e67c7be20610920aeeb616f5bc12c5529 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 3 Jul 2026 15:33:42 +0200 Subject: [PATCH 262/343] [nrf52] fix crash report for native build (#17371) --- esphome/components/nrf52/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 184d41e0f3d..7ce973a2a9c 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -697,10 +697,22 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> addr2line = find_tool("addr2line") if addr2line is None: return False - elf = CORE.relative_pioenvs_path(CORE.name, "firmware.elf") - if not elf.exists(): - _LOGGER.warning("%s does not exists", elf) + + candidates = [ + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "firmware.elf"), + ] + + elf = next((path for path in candidates if path.exists()), None) + + if elf is None: + _LOGGER.warning( + "None of the expected ELF files exist:\n%s", + "\n".join(str(p) for p in candidates), + ) return False + _LOGGER.error("=== CRASH ===") _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc)) _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) From fd16eec416d29b17c9e85e7744a27fef3e1bd4fe Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 3 Jul 2026 18:16:55 +0200 Subject: [PATCH 263/343] [nrf52] switch nrf52 builds to native sdk by default (#17319) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: ESPHome Device Builder --- esphome/components/nrf52/__init__.py | 30 ++++++--- esphome/components/nrf52/framework.py | 22 ++++++ esphome/components/zephyr/__init__.py | 13 ++++ esphome/components/zephyr/const.py | 1 + .../components/zephyr_mcumgr/ota/__init__.py | 17 ++++- script/ci_memory_impact_extract.py | 67 +++++++++++++++---- tests/components/api/test.nrf52-adafruit.yaml | 4 +- .../components/nrf52/test.nrf52-adafruit.yaml | 2 - 8 files changed, 126 insertions(+), 30 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7ce973a2a9c..7c17eadd1a0 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -117,7 +117,7 @@ def set_core_data(config: ConfigType) -> ConfigType: def _resolve_toolchain(config: ConfigType) -> ConfigType: if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF) return config @@ -439,8 +439,8 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" - HEX_PATH = "zephyr/zephyr.hex" - HEX_MERGED_PATH = "zephyr/merged.hex" + HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1, only generated when OTA is disabled + HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent if (build_dir / UF2_PATH).is_file(): @@ -777,6 +777,11 @@ def _generate_cmake_lists() -> bool: ) +def _copy_if_exists(src: Path, dst: Path) -> None: + if src.is_file(): + shutil.copy2(src, dst) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -828,15 +833,18 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError("nRF52 native build failed") - # Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and - # merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match - # get_download_types (which mirrors the platformio build output layout). zephyr_dir = build_dir / "zephyr" - west_out = zephyr_dir / "zephyr" - for filename in ["zephyr.uf2"]: - src = west_out / filename - if src.is_file(): - shutil.copy2(src, zephyr_dir / filename) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. + # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); + # copy files to match get_download_types layout. + if framework_ver < cv.Version(2, 9, 2): + west_out = zephyr_dir + else: + west_out = zephyr_dir / "zephyr" + _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2") + _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") + _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes _GENPKG_PARAMS = { diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 7cb1164482a..640aa07fbfc 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -2,10 +2,12 @@ import logging import os from pathlib import Path import platform +import shutil import tempfile import platformdirs +import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -134,6 +136,23 @@ def get_build_env() -> dict: return env +def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: + # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that + # Python 3.12+ flags with SyntaxWarning (a future version will reject it). + uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" + if not uf2conv.exists(): + return + content = uf2conv.read_text(encoding="utf-8") + patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") + if patched == content: + return + # Write atomically so a concurrent build never sees a truncated file + tmp = uf2conv.with_suffix(".py.tmp") + tmp.write_text(patched, encoding="utf-8") + shutil.copymode(uf2conv, tmp) + tmp.replace(uf2conv) + + def check_and_install() -> None: version = _get_version_str() python_env_path = _get_python_env_path(version) @@ -195,6 +214,9 @@ def check_and_install() -> None: ] if not run_command_ok(cmd, cwd=framework_path): raise EsphomeError(f"Can't update nRF Connect SDK {version}") + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index cd077a142f0..d6c45a744c9 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -18,6 +18,7 @@ from .const import ( KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_SYSBUILD, KEY_USER, KEY_ZEPHYR, zephyr_ns, @@ -55,6 +56,7 @@ class ZephyrData(TypedDict): pm_static: list[Section] user: dict[str, list[str]] kconfig: str + sysbuild: bool def zephyr_set_core_data(config: ConfigType) -> None: @@ -69,6 +71,10 @@ def zephyr_set_core_data(config: ConfigType) -> None: pm_static=[], user={}, kconfig="", + # When OTA is disabled, the image is built without a bootloader even if the + # config says `bootloader: mcuboot`, so the image can be smaller. This was + # the default behaviour in SDK 2.6.1. + sysbuild=False, ) @@ -286,6 +292,13 @@ def copy_files() -> None: CORE.relative_build_path("zephyr/Kconfig"), kconfig ) + sysbuild_conf = "" + if zephyr_data()[KEY_SYSBUILD]: + sysbuild_conf = "SB_CONFIG_BOOTLOADER_MCUBOOT=y\n" + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/sysbuild.conf"), sysbuild_conf + ) + if changed: # A configure-time input changed; drop the CMake cache so the build # can't reuse stale configure results (the native sdk-nrf toolchain diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f2de861e314..497e5f3ce54 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -13,6 +13,7 @@ KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" KEY_USER: Final = "user" +KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") CdcAcm = zephyr_ns.class_("CdcAcm", cg.Component) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index b0d86190b8d..0ff1825bd1a 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -6,9 +6,19 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_data, ) -from esphome.components.zephyr.const import BOOTLOADER_MCUBOOT, KEY_BOOTLOADER +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOOTLOADER, + KEY_SYSBUILD, +) import esphome.config_validation as cv -from esphome.const import CONF_HARDWARE_UART, CONF_ID, Framework +from esphome.const import ( + CONF_HARDWARE_UART, + CONF_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + Framework, +) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -139,3 +149,6 @@ async def to_code(config: ConfigType) -> None: }}; """ ) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver >= cv.Version(2, 9, 2): + zephyr_data()[KEY_SYSBUILD] = True diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index feacc2b1aff..20a737cdbf6 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Extract memory usage statistics from ESPHome build output. -This script parses the PlatformIO build output to extract RAM and flash -usage statistics for a compiled component. It's used by the CI workflow to +This script parses the build output to extract RAM and flash usage +statistics for a compiled component. It's used by the CI workflow to compare memory usage between branches. The script reads compile output from stdin and looks for the standard @@ -10,6 +10,13 @@ PlatformIO output format: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) +or the linker memory usage table printed by Zephyr native builds +(e.g. nRF52 with the sdk-nrf toolchain): + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + IDT_LIST: 0 GB 32 KB 0.00% + Optionally performs detailed memory analysis if a build directory is provided. """ @@ -34,20 +41,43 @@ _RAM_PATTERN = re.compile(r"RAM:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes" _FLASH_PATTERN = re.compile(r"Flash:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes") _BUILD_PATH_PATTERN = re.compile(r"Build path: (.+)") +# Zephyr native builds print the GNU ld --print-memory-usage table instead of +# the PlatformIO summary. Only the FLASH and RAM regions are real memory +# (IDT_LIST is a build-time pseudo-region discarded from the final image). +# Each cell is humanized to the largest unit that divides evenly, so used +# sizes are not always plain bytes (zero prints as "0 GB"). +_ZEPHYR_RAM_PATTERN = re.compile( + r"^\s*RAM:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_FLASH_PATTERN = re.compile( + r"^\s*FLASH:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_UNIT_MULTIPLIERS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + + +def _zephyr_bytes(matches: list[tuple[str, str]]) -> int: + """Sum humanized (value, unit) pairs from the Zephyr memory table.""" + return sum(int(value) * _ZEPHYR_UNIT_MULTIPLIERS[unit] for value, unit in matches) + def extract_from_compile_output( output_text: str, ) -> tuple[int | None, int | None, str | None]: - """Extract memory usage and build directory from PlatformIO compile output. + """Extract memory usage and build directory from compile output. Supports multiple builds (for component groups or isolated components). When test_build_components.py creates multiple builds, this sums the memory usage across all builds. - Looks for lines like: + Looks for PlatformIO lines like: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) + and Zephyr (native west build) linker table rows like: + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + Also extracts build directory from lines like: INFO Compiling app... Build path: /path/to/build @@ -61,12 +91,20 @@ def extract_from_compile_output( ram_matches = _RAM_PATTERN.findall(output_text) flash_matches = _FLASH_PATTERN.findall(output_text) - if not ram_matches or not flash_matches: + # Zephyr native builds print the linker memory table instead + zephyr_ram_matches = _ZEPHYR_RAM_PATTERN.findall(output_text) + zephyr_flash_matches = _ZEPHYR_FLASH_PATTERN.findall(output_text) + + if not (ram_matches or zephyr_ram_matches) or not ( + flash_matches or zephyr_flash_matches + ): return None, None, None # Sum all builds (handles multiple component groups) total_ram = sum(int(match) for match in ram_matches) total_flash = sum(int(match) for match in flash_matches) + total_ram += _zephyr_bytes(zephyr_ram_matches) + total_flash += _zephyr_bytes(zephyr_flash_matches) # Extract build directory from ESPHome's explicit build path output # Look for: INFO Compiling app... Build path: /path/to/build @@ -202,20 +240,23 @@ def main() -> int: ) if ram_bytes is None or flash_bytes is None: - print("Failed to extract memory usage from compile output", file=sys.stderr) - print("Expected lines like:", file=sys.stderr) print( - " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)", - file=sys.stderr, - ) - print( - " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)", + "Failed to extract memory usage from compile output\n" + "Expected lines like:\n" + " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" + "or a Zephyr linker memory usage table like:\n" + " Memory region Used Size Region Size %age Used\n" + " FLASH: 90624 B 796 KB 11.12%\n" + " RAM: 22432 B 256 KB 8.56%", file=sys.stderr, ) return 1 # Count how many builds were found - num_builds = len(_RAM_PATTERN.findall(compile_output)) + num_builds = len(_RAM_PATTERN.findall(compile_output)) + len( + _ZEPHYR_RAM_PATTERN.findall(compile_output) + ) if num_builds > 1: print( diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml index 18bf23d7106..347480bab6c 100644 --- a/tests/components/api/test.nrf52-adafruit.yaml +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -1,7 +1,7 @@ +<<: !include common.yaml + network: enable_ipv6: true openthread: tlv: 0E080000000000010000 - -api: diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 3ae48b2a5f8..5fa0d6e88fb 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -19,5 +19,3 @@ nrf52: reg0: voltage: 2.1V uicr_erase: true - framework: - version: "2.6.1-b" From 187cd51867387475431dcb17c87d3e7cd3da9e11 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:47:08 -0400 Subject: [PATCH 264/343] [ci] Carry native-toolchain needs on component test batches (#17359) --- .github/workflows/ci.yml | 14 ++++++++------ script/determine-jobs.py | 20 +++++++++++++++----- tests/script/test_determine_jobs.py | 18 ++++++++++-------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2016739c4fa..9310b45b4ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -795,7 +795,7 @@ jobs: if: always() test-build-components-split: - name: Test components batch (${{ matrix.components }}) + name: Test components batch (${{ matrix.batch.components }}) runs-on: ubuntu-24.04 needs: - common @@ -809,7 +809,7 @@ jobs: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} matrix: - components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} + batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: - name: Show disk space run: | @@ -817,7 +817,7 @@ jobs: df -h - name: List components - run: echo ${{ matrix.components }} + run: echo ${{ matrix.batch.components }} - name: Cache apt packages uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 @@ -833,8 +833,10 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install (restore-only) - # A batch may contain no esp32 build, so never save -- just reuse the - # shared install the dev tidy jobs already cached when present. + # Only batches whose test platforms include esp32 need the native + # ESP-IDF install; never save -- just reuse the shared install the + # dev tidy jobs already cached when present. + if: matrix.batch.needs_idf uses: ./.github/actions/cache-esp-idf with: restore-only: true @@ -868,7 +870,7 @@ jobs: fi # Convert space-separated components to comma-separated for Python script - components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') + components_csv=$(echo "${{ matrix.batch.components }}" | tr ' ' ',') # Only isolate directly changed components when targeting dev branch # For beta/release branches, group everything for faster CI diff --git a/script/determine-jobs.py b/script/determine-jobs.py index af3e83f96b3..756f3884b82 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -1338,7 +1338,7 @@ def main() -> None: # Split components into batches for CI testing # This intelligently groups components with similar bus configurations - component_test_batches: list[str] + component_test_batches: list[dict[str, Any]] = [] if changed_components_with_tests: tests_dir = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH @@ -1363,10 +1363,20 @@ def main() -> None: batch_size=COMPONENT_TEST_BATCH_SIZE, directly_changed=batch_directly_changed, ) - # Convert batches to space-separated strings for CI matrix - component_test_batches = [" ".join(batch) for batch in batches] - else: - component_test_batches = [] + # Convert batches to CI matrix entries: the component list plus which + # native toolchain installs the batch's test platforms need, so the + # workflow only restores the matching multi-GB toolchain caches. + for batch in batches: + platforms: set[str] = set() + for component in batch: + platforms.update(get_component_test_platforms(component)) + component_test_batches.append( + { + "components": " ".join(batch), + "needs_idf": any(p.startswith("esp32") for p in platforms), + "needs_nrf": any(p.startswith("nrf52") for p in platforms), + } + ) output: dict[str, Any] = { "core_ci": run_core_ci, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index d4c13fd3fbd..2f038155c0d 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -231,14 +231,16 @@ def test_main_all_tests_should_run( assert output["memory_impact"]["should_run"] == "false" assert output["cpp_unit_tests_run_all"] is False assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"] - # component_test_batches should be present and be a list of space-separated strings + # component_test_batches should be a list of matrix entries carrying the + # space-separated component list and the toolchain-need flags assert "component_test_batches" in output assert isinstance(output["component_test_batches"], list) - # Each batch should be a space-separated string of component names for batch in output["component_test_batches"]: - assert isinstance(batch, str) + assert isinstance(batch, dict) # Should contain at least one component (no empty batches) - assert len(batch) > 0 + assert len(batch["components"]) > 0 + assert isinstance(batch["needs_idf"], bool) + assert isinstance(batch["needs_nrf"], bool) def test_main_no_tests_should_run( @@ -2417,16 +2419,16 @@ def test_component_batching_beta_branch_40_per_batch( assert len(batches) == 3, f"Expected 3 batches, got {len(batches)}" # Each batch should have approximately 40 components (all weight=1, groupable) - for i, batch_str in enumerate(batches): - batch_components = batch_str.split() + for i, batch in enumerate(batches): + batch_components = batch["components"].split() assert len(batch_components) == 40, ( f"Batch {i} should have 40 components, got {len(batch_components)}" ) # Verify all 120 components are in batches all_components = [] - for batch_str in batches: - all_components.extend(batch_str.split()) + for batch in batches: + all_components.extend(batch["components"].split()) assert len(all_components) == 120 assert set(all_components) == set(component_names) From 7ad43358c2e6001656f3181ac6dec11609fcc51b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:28:29 +0200 Subject: [PATCH 265/343] [zigbee] Bump zigbee sdk to 2.0.2 (#16869) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 16 + esphome/components/zigbee/const.py | 15 +- esphome/components/zigbee/const_esp32.py | 30 +- .../zigbee/zigbee_attribute_esp32.cpp | 75 ++-- .../zigbee/zigbee_attribute_esp32.h | 7 +- esphome/components/zigbee/zigbee_ep_esp32.py | 12 +- esphome/components/zigbee/zigbee_esp32.cpp | 353 +++++++++--------- esphome/components/zigbee/zigbee_esp32.h | 57 +-- esphome/components/zigbee/zigbee_esp32.py | 36 +- .../components/zigbee/zigbee_helpers_esp32.c | 99 ++--- .../components/zigbee/zigbee_helpers_esp32.h | 13 +- esphome/components/zigbee/zigbee_zephyr.py | 2 +- esphome/idf_component.yml | 6 +- sdkconfig.defaults.esp32c6 | 1 - tests/components/zigbee/common_esp32.yaml | 2 +- 15 files changed, 358 insertions(+), 366 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index c75b0773d29..444012bcd8b 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -8,6 +8,9 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME @@ -52,11 +55,21 @@ CODEOWNERS = ["@luar123", "@tomaszduda23"] CONFLICTS_WITH = ["openthread"] + +def _check_report_deprecation(value: str) -> str: + if str(value).lower() in ("coordinator", "enable"): + _LOGGER.warning( + "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + ) + return value + + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( cv.requires_component("zigbee"), cv.requires_component("esp32"), + _check_report_deprecation, cv.enum(REPORT, lower=True), ) } @@ -111,7 +124,10 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, only_on_variant( supported=[ + VARIANT_ESP32S31, VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, VARIANT_ESP32C5, VARIANT_ESP32C6, ] diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 7d0e14c67aa..dd36f815ab5 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -55,6 +55,7 @@ REPORT = { "coordinator": report.ZIGBEE_REPORT_COORDINATOR, "enable": report.ZIGBEE_REPORT_ENABLE, "force": report.ZIGBEE_REPORT_FORCE, + "default": report.ZIGBEE_REPORT_DEFAULT, } CONF_ON_JOIN = "on_join" @@ -63,13 +64,13 @@ CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", + "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN + "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE + "MAINS_THREE_PHASE": 0x02, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE + "BATTERY": 0x03, # ZB_ZCL_BASIC_POWER_SOURCE_BATTERY + "DC_SOURCE": 0x04, # ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE + "EMERGENCY_MAINS_CONST": 0x05, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST + "EMERGENCY_MAINS_TRANSF": 0x06, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF } KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb507320eb0..81a8fc52cda 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -13,27 +13,25 @@ CONF_ATTRIBUTE_ID = "attribute_id" KEY_BS_EP = "binary_sensor_ep" KEY_SENSOR_EP = "sensor_ep" -ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") DEVICE_ID = { - "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, - "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, - "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, + "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), + "SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"), + "CUSTOM_ATTR": 0xFFF2, } -cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") CLUSTER_ID = { - "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, - "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "ANALOG_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } -cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") CLUSTER_ROLE = { - "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, + "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), } -attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { - "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, - "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, - "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, - "SINGLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_DOUBLE, + "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, + "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, + "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, + "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, + "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 0a06792c594..c6f2aa0af6a 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -12,63 +12,68 @@ void ZigbeeAttribute::set_attr_() { if (!this->zb_->is_connected()) { return; } - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, - this->attr_id_, this->value_p_, false); + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_status_t state = ezb_zcl_set_attr_value(this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, + EZB_ZCL_STD_MANUF_CODE, this->value_p_, false); if (this->force_report_) { this->report_(true); } this->set_attr_requested_ = false; // Check for error - if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + if (state != EZB_ZCL_STATUS_SUCCESS) { ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); } - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } void ZigbeeAttribute::report_(bool has_lock) { - if (!this->zb_->is_connected()) { + if (!this->zb_->is_connected() || !this->report_enabled) { return; } - if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_report_attr_cmd_t cmd = {}; - cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT; - cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI; - cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; - cmd.zcl_basic_cmd.dst_endpoint = 1; - cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; - cmd.clusterID = this->cluster_id_; - cmd.attributeID = this->attr_id_; + if (has_lock or esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_report_attr_cmd_t cmd = {}; + cmd.cmd_ctrl.fc.direction = EZB_ZCL_CMD_DIRECTION_TO_CLI; + cmd.cmd_ctrl.fc.dis_default_rsp = 1; + cmd.cmd_ctrl.dst_addr.addr_mode = EZB_ADDR_MODE_SHORT; + cmd.cmd_ctrl.dst_addr.u.short_addr = 0x0000; + cmd.cmd_ctrl.dst_ep = 1; + cmd.cmd_ctrl.src_ep = this->endpoint_id_; + cmd.cmd_ctrl.cluster_id = this->cluster_id_; + cmd.cmd_ctrl.fc.manuf_specific = 0; + cmd.payload.attr_id = this->attr_id_; - esp_zb_zcl_report_attr_cmd_req(&cmd); + ezb_zcl_report_attr_cmd_req(&cmd); if (!has_lock) { - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } } -esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { - esp_zb_zcl_reporting_info_t reporting_info = {}; - reporting_info.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV; - reporting_info.ep = this->endpoint_id_; - reporting_info.cluster_id = this->cluster_id_; - reporting_info.cluster_role = this->role_; - reporting_info.attr_id = this->attr_id_; - reporting_info.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC; - reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; - reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ - reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ - reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ - reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ - reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ - - return reporting_info; +void ZigbeeAttribute::setup_reporting() { + ezb_zcl_reporting_info_t reporting_info = ezb_zcl_reporting_info_find( + this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, EZB_ZCL_STD_MANUF_CODE); + if (reporting_info == EZB_ZCL_INVALID_REPORTING_INFO) { + ESP_LOGD(TAG, "Could not find reporting info for attribute 0x%04X in cluster 0x%04X in endpoint %u", this->attr_id_, + this->cluster_id_, this->endpoint_id_); + this->report_enabled = false; + this->force_report_ = false; + } else { + ESP_LOGD(TAG, "Found reporting info for attr 0x%04X in cluster 0x%04X", this->attr_id_, this->cluster_id_); + ezb_zcl_attr_variable_t delta = {.u64 = 0}; + ezb_zcl_reporting_info_update_default_interval(reporting_info, 0, 65000); + ezb_zcl_reporting_info_update(reporting_info, 0, 65000, &delta); + if (ezb_zcl_reporting_start_attr_report(reporting_info) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not start reporting for attribute"); + } + } } -void ZigbeeAttribute::set_report(bool force) { +void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; - this->force_report_ = force; + if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { + this->force_report_ = true; + } } void ZigbeeAttribute::loop() { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index e978fcf2097..b5afb579107 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -9,7 +9,7 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" #include "zigbee_esp32.h" #ifdef USE_SENSOR @@ -22,6 +22,7 @@ namespace esphome::zigbee { enum ZigbeeReportT { + ZIGBEE_REPORT_DEFAULT, ZIGBEE_REPORT_COORDINATOR, ZIGBEE_REPORT_ENABLE, ZIGBEE_REPORT_FORCE, @@ -41,10 +42,10 @@ class ZigbeeAttribute final : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - esp_zb_zcl_reporting_info_t get_reporting_info(); + void setup_reporting(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } - void set_report(bool force); + void set_report(ZigbeeReportT report); #ifdef USE_SENSOR template void connect(sensor::Sensor *sensor); #endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 5dd76e99038..f4efa7bf4e1 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,7 +27,7 @@ ep_configs: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: REPORT["default"], CONF_DEVICE: None, }, { @@ -36,11 +36,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, @@ -56,7 +56,7 @@ ep_configs: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: REPORT["default"], CONF_DEVICE: None, }, { @@ -65,11 +65,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 1809f181bea..03457312be6 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -36,121 +36,143 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { return zcl_str; } -static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { - if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { - ESP_LOGE(TAG, "Start network steering failed!"); +void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { + if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + return; } + if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Start top level commissioning failed!"); + } + esp_zigbee_lock_release(); } -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { static uint8_t steering_retry_count = 0; - uint32_t *p_sg_p = signal_struct->p_app_signal; - esp_err_t err_status = signal_struct->esp_err_status; - esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; - esp_zb_zdo_signal_leave_params_t *leave_params = NULL; - switch (sig_type) { - case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ezb_app_signal_type_t signal_type = ezb_app_signal_get_type(app_signal); + switch (signal_type) { + case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + if (ezb_bdb_is_factory_new()) { + global_zigbee->defer([]() { global_zigbee->setup_reporting(); }); + } else { + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); + } break; - case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: - case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: - if (err_status == ESP_OK) { - ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + case EZB_BDB_SIGNAL_DEVICE_FIRST_START: + case EZB_BDB_SIGNAL_DEVICE_REBOOT: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); global_zigbee->started = true; - if (esp_zb_bdb_is_factory_new()) { + if (ezb_bdb_is_factory_new()) { global_zigbee->factory_new = true; ESP_LOGD(TAG, "Start network steering"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } } else { - ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", - esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); - ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, - 1000); + ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); + }); } - break; - case ESP_ZB_BDB_SIGNAL_STEERING: - if (err_status == ESP_OK) { + } break; + case EZB_BDB_SIGNAL_STEERING: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { steering_retry_count = 0; - ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), - esp_zb_get_current_channel()); + ezb_extpanid_t extended_pan_id; + ezb_nwk_get_extended_panid(&extended_pan_id); + ESP_LOGD(TAG, "Joined network successfully: PAN ID(0x%04hx, EXT: 0x%llx), Channel(%d), Short Address(0x%04hx)", + ezb_nwk_get_panid(), extended_pan_id.u64, ezb_nwk_get_current_channel(), ezb_nwk_get_short_address()); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } else { - ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } else { - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } } - break; - case ESP_ZB_ZDO_SIGNAL_LEAVE: - leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); - if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { - esp_zb_factory_reset(); + } break; + case EZB_ZDO_SIGNAL_LEAVE: { + const ezb_zdo_signal_leave_params_t *leave_params = + (const ezb_zdo_signal_leave_params_t *) ezb_app_signal_get_params(app_signal); + if (leave_params->leave_type == EZB_ZDO_LEAVE_TYPE_RESET) { + esp_zigbee_factory_reset(); } - break; + } break; default: - ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, - esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Zigbee APP Signal: %s(type: 0x%02x)", ezb_app_signal_to_string(signal_type), signal_type); break; } + return true; } -static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { - esp_err_t ret = ESP_OK; - ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); - ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, - "Received message: error status(%d)", message->info.status); - ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", - message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); - return ret; +static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { + ESP_RETURN_ON_FALSE(message, , TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == EZB_ZCL_STATUS_SUCCESS, , TAG, "Received message: error status(%d)", + message->info.status); + ESP_LOGD(TAG, "ZCL SetAttributeValue message for endpoint(%d) cluster(0x%04x) %s with status(0x%02x)", + message->info.dst_ep, message->info.cluster_id, + message->info.cluster_role == EZB_ZCL_CLUSTER_SERVER ? "server" : "client", message->info.status); } -static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { - esp_err_t ret = ESP_OK; +static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, void *message) { switch (callback_id) { - case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: - ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: + zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; +#ifdef ESPHOME_LOG_HAS_VERBOSE + case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { + ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; + ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); + } break; +#endif default: - ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; } - return ret; } -void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { - esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); - this->endpoint_list_[endpoint_id] = - std::tuple(device_id, cluster_list); - // Add basic cluster - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); - // Add identify cluster if not already present - if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == - nullptr) { - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) { + ezb_af_ep_config_t config = { + .ep_id = endpoint_id, + .app_profile_id = EZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0, + }; + ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config); + if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id); } + // Add basic cluster + this->update_basic_cluster_(ep_desc); + // Add identify cluster if not already present + this->add_cluster(endpoint_id, EZB_ZCL_CLUSTER_ID_IDENTIFY, EZB_ZCL_CLUSTER_SERVER); } void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { - esp_zb_attribute_list_t *attr_list; - if (cluster_id == 0) { - attr_list = create_basic_cluster_(); - } else { - attr_list = esphome_zb_default_attr_list_create(cluster_id); + if (cluster_id == EZB_ZCL_CLUSTER_ID_BASIC) { + return; } - this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + ESP_LOGE(TAG, "Endpoint %u does not exist, cannot add cluster 0x%04X", endpoint_id, cluster_id); + return; + } + esphome_zb_add_or_update_cluster(cluster_id, ep_desc, role); + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { @@ -166,131 +188,117 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu }; } -esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { - esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { - .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, - .power_source = this->basic_cluster_data_.power_source, - }; - esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, - this->basic_cluster_data_.manufacturer); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); - return attr_list; +void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { + ezb_zcl_cluster_desc_t cluster_desc = + ezb_af_endpoint_get_cluster_desc(ep_desc, EZB_ZCL_CLUSTER_ID_BASIC, EZB_ZCL_CLUSTER_SERVER); + if (cluster_desc == NULL) { + ezb_zcl_basic_cluster_config_t basic_cluster_cfg = { + .zcl_version = EZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = this->basic_cluster_data_.power_source, + }; + cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER); + } + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, + this->basic_cluster_data_.model); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list) { - esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, - .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = static_cast(device_id), - .app_device_version = 0}; - return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +void ZigbeeComponent::setup_reporting() { + ESP_LOGD(TAG, "Setting up reporting for all attributes"); + esp_zigbee_lock_acquire(portMAX_DELAY); + for (auto &[_, attribute] : this->attributes_) { + attribute->setup_reporting(); + } + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); + esp_zigbee_lock_release(); } -static void esp_zb_task(void *pv_parameters) { - if (esp_zb_start(false) != ESP_OK) { +static void ezb_task(void *pv_parameters) { + if (esp_zigbee_start(false) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); vTaskDelete(NULL); } - if (global_zigbee->is_battery_powered()) { - ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(false); - } else { - esp_zb_set_node_descriptor_power_source(true); + esp_zigbee_launch_mainloop(); + + esp_zigbee_deinit(); + + vTaskDelete(NULL); +} + +ZigbeeComponent::ZigbeeComponent() { + esp_zigbee_platform_config_t platform_config = { + .storage_partition_name = "nvs", + .radio_config = EZB_DEFAULT_RADIO_CONFIG(), + }; + esp_zigbee_device_config_t device_config = { + .device_type = this->device_role_, + .install_code_policy = false, + }; +#ifdef CONFIG_ZB_ZCZR + esp_zigbee_zczr_config_s zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + device_config.zczr_config = zb_zczr_cfg; +#else + esp_zigbee_zed_config_s zb_zed_cfg = { + .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + device_config.zed_config = zb_zed_cfg; +#endif + esp_zigbee_config_t config = {.device_config = device_config, .platform_config = platform_config}; + if (esp_zigbee_init(&config) != ESP_OK) { + ESP_LOGE(TAG, "Could not initialize Zigbee"); + this->mark_failed(); + return; } - esp_zb_stack_main_loop(); + this->dev_desc_ = ezb_af_create_device_desc(); } void ZigbeeComponent::setup() { global_zigbee = this; - esp_zb_platform_config_t config = {}; - config.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(); - config.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(); #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); return; } #endif - if (esp_zb_platform_config(&config) != ESP_OK) { + ezb_aps_secur_enable_distributed_security(false); + ezb_nwk_set_min_join_lqi(32); + if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { + ESP_LOGE(TAG, "Could not set application signal handler"); this->mark_failed(); return; } - esp_zb_cfg_t zb_nwk_cfg = { - .esp_zb_role = this->device_role_, - .install_code_policy = false, - }; -#ifdef ZB_ROUTER_ROLE - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; - zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; -#else - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; -#endif - esp_zb_init(&zb_nwk_cfg); - - esp_err_t ret; - for (auto const &[key, val] : this->attribute_list_) { - esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); - ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), - esp_err_to_name(ret)); - } else { - ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), - std::get<2>(key)); -#ifdef ESPHOME_LOG_HAS_VERBOSE - // Dump cluster attributes in verbose log - ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); - esp_zb_attribute_list_t *attr_list = val; - while (attr_list) { - esp_zb_zcl_attr_t *attr = &attr_list->attribute; - ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); - attr_list = attr_list->next; - } -#endif - } - } - this->attribute_list_.clear(); - - for (auto const &[ep_id, dev_id] : this->endpoint_list_) { - if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { - ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); - } - } - this->endpoint_list_.clear(); - - if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { ESP_LOGE(TAG, "Could not register the endpoint list"); this->mark_failed(); return; } - esp_zb_core_action_handler_register(zb_action_handler); + ezb_zcl_core_action_handler_register(zb_action_handler); - if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); this->mark_failed(); return; } - for (auto &[_, attribute] : this->attributes_) { - if (attribute->report_enabled) { - esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); - ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); - if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { - ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", - reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); - } - } - } - xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); + + uint8_t power_source = static_cast(this->is_battery_powered() ? EZB_AF_NODE_POWER_SOURCE_RECHARGEABLE_BATTERY + : EZB_AF_NODE_POWER_SOURCE_CONSTANT_POWER); + ezb_af_node_power_desc_t desc = { + .current_power_mode = EZB_AF_NODE_POWER_MODE_SYNC_ON_WHEN_IDLE, + .available_power_sources = power_source, + .current_power_source = power_source, + .current_power_source_level = EZB_AF_NODE_POWER_SOURCE_LEVEL_100_PERCENT, + }; + ezb_af_set_node_power_desc(&desc); + + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 24, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } @@ -303,25 +311,28 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n" " Device is joined to the network: %s\n" " Current channel: %d\n" " Short addr: 0x%04X\n" " Short pan id: 0x%04X", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), - YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), - esp_zb_get_pan_id()); - esp_zb_lock_release(); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), + ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 25f53a1d6e4..11289843a84 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -8,9 +8,8 @@ #include #include -#include "esp_zigbee_core.h" -#include "zboss_api.h" -#include "ha/esp_zigbee_ha_standard.h" +#include "esp_zigbee.h" +#include "ezbee/zha.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "zigbee_helpers_esp32.h" @@ -24,12 +23,10 @@ namespace esphome::zigbee { /* Zigbee configuration */ static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; +static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ -#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ - { .radio_mode = ZB_RADIO_MODE_NATIVE, } - -#define ESP_ZB_DEFAULT_HOST_CONFIG() \ - { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } +#define EZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ESP_ZIGBEE_RADIO_MODE_NATIVE, } uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); @@ -37,14 +34,15 @@ class ZigbeeAttribute; class ZigbeeComponent final : public Component { public: + ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; - esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); - void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); + void setup_reporting(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, @@ -53,15 +51,18 @@ class ZigbeeComponent final : public Component { template void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + static bool app_signal_handler(const ezb_app_signal_t *app_signal); + static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); + void factory_reset() { - esp_zb_lock_acquire(portMAX_DELAY); - esp_zb_factory_reset(); // triggers a reboot - esp_zb_lock_release(); + esp_zigbee_lock_acquire(portMAX_DELAY); + esp_zigbee_factory_reset(); // triggers a reboot + esp_zigbee_lock_release(); } template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } - bool is_battery_powered() { return this->basic_cluster_data_.power_source == ESP_ZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } + bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } bool is_started() { return this->started; } bool is_connected() { return this->connected_; } std::atomic started = false; @@ -76,25 +77,20 @@ class ZigbeeComponent final : public Component { uint8_t power_source; } basic_cluster_data_; bool connected_ = false; -#ifdef ZB_ED_ROLE - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#ifdef CONFIG_ZB_ZED + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; #else - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_ROUTER; #endif - esp_zb_attribute_list_t *create_basic_cluster_(); + void update_basic_cluster_(ezb_af_ep_desc_t ep_desc); template void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p); - // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards - // value tuple could be replaced by struct - std::map> endpoint_list_; - // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role - std::map, esp_zb_attribute_list_t *> attribute_list_; // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); + ezb_af_device_desc_t dev_desc_; CallbackManager join_cb_{}; }; @@ -125,8 +121,15 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { - esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return; + } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 086cdcc2672..f19bc97be71 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,6 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -41,7 +40,6 @@ from .const import ( CONF_ROUTER, KEY_ZIGBEE, POWER_SOURCE, - REPORT, ZigbeeAttribute, ) from .const_esp32 import ( @@ -76,7 +74,7 @@ def get_c_type(attr_type: str) -> Any | None: return cg.double if "STRING" in attr_type: return cg.std_string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) return None @@ -89,14 +87,14 @@ def get_cv_by_type(attr_type: str) -> Any | None: return cv.float_ if "STRING" in attr_type: return cv.string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return cv.positive_int raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") def get_default_by_type(attr_type: str) -> str | bool | int | float: - if attr_type == "CHAR_STRING": + if attr_type == "STRING": return "" if attr_type == "BOOL": return False @@ -134,7 +132,6 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: ) as f: partitions_tab = f.read() for partition, types in [ - ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), ]: if partition not in partitions_tab: @@ -191,14 +188,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: { CONF_ATTRIBUTE_ID: 0x100, CONF_VALUE: (apptype << 16) | 0xFFFF, - CONF_TYPE: "U32", + CONF_TYPE: "UINT32", }, ) ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { CONF_ATTRIBUTE_ID: 0x75, CONF_VALUE: bacunit, - CONF_TYPE: "16BIT_ENUM", + CONF_TYPE: "ENUM16", }, ) setup_attributes(config, ep[CONF_CLUSTERS]) @@ -233,15 +230,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) else: add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) - add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) if CONF_WIFI in CORE.config: add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) - # The pre-built Zigbee library uses esp_log_default_level which requires - # dynamic log level control to be enabled - add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) - # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). - require_libc_picolibc_newlib_compat() async def attributes_to_code( @@ -274,11 +264,8 @@ async def attributes_to_code( await cg.register_component(attr_var, attr) cg.add(attr_var.add_attr(attr[CONF_VALUE])) - if CONF_REPORT in attr and attr[CONF_REPORT] in [ - REPORT["enable"], - REPORT["force"], - ]: - cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + if CONF_REPORT in attr: + cg.add(attr_var.set_report(attr[CONF_REPORT])) if CONF_DEVICE in attr: device = await cg.get_variable(attr[CONF_DEVICE]) @@ -287,20 +274,15 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": - add_idf_component( - name="espressif/esp-zboss-lib", - ref="1.6.4", - ) add_idf_component( name="espressif/esp-zigbee-lib", - ref="1.6.8", + ref="2.0.2", ) # add sdkconfigs later so they can overwrite esp32 defaults CORE.add_job(_zigbee_add_sdkconfigs, config) # add partitions for zigbee - add_partition("zb_storage", "data", "fat", 0x4000) # 16KB add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size # create endpoints @@ -316,7 +298,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": var.set_basic_cluster( config[CONF_MODEL], "esphome", - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) for ep in ep_list: diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 5254818df49..150be612f68 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -2,78 +2,59 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "ha/esp_zigbee_ha_standard.h" #include "zigbee_helpers_esp32.h" +#include "ezbee/zha.h" -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { - esp_err_t ret; - ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); - ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + if (attr_desc != NULL) { + return ezb_zcl_attr_desc_set_value(attr_desc, value_p); } - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, - esp_err_to_name(ret)); - } - return ret; + return esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); } -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { - esp_err_t ret; - ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - ret = esp_zb_cluster_list_add_analog_input_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); - break; - default: - ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask) { + if (ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role_mask) != NULL) { + // Cluster already exists, nothing to do + return EZB_ERR_NONE; + } + ezb_zcl_cluster_desc_t cluster_desc; + cluster_desc = esphome_zb_default_cluster_dscr_create(cluster_id, role_mask); + return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); +} + +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) { + switch (cluster_id) { + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); + default: { + ezb_zcl_custom_cluster_config_t config = {0}; + config.cluster_id = cluster_id; + return ezb_zcl_custom_create_cluster_desc(&config, role_mask); } } - return ret; } -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_create(NULL); - default: - return esp_zb_zcl_attr_list_create(cluster_id); - } -} - -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); default: - return ESP_FAIL; + return EZB_ERR_NOT_FOUND; } } diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h index 0650c1689f7..6898068b442 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -8,15 +8,14 @@ extern "C" { #endif -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask); -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, - void *value_p); -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask); +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, + void *value_p); #ifdef __cplusplus } diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 39ecadfddf8..1647fb28aea 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -168,7 +168,7 @@ async def _attr_to_code(config: ConfigType) -> None: ), zigbee_assign( basic_attrs.power_source, - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ), zigbee_set_string(basic_attrs.location_id, ""), zigbee_assign( diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4f36e4dbe63..7ad41fa978e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -47,12 +47,8 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" - espressif/esp-zboss-lib: - version: 1.6.4 - rules: - - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/esp-zigbee-lib: - version: 1.6.8 + version: 2.0.2 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 6dd5f4f329a..63dbeffd772 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -11,4 +11,3 @@ CONFIG_OPENTHREAD_RADIO_NATIVE=y # zigbee CONFIG_ZB_ENABLED=y CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 82a523fc7c4..787afc4476c 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -4,7 +4,7 @@ packages: binary_sensor: - platform: template name: "Garage Door Open 10" - report: "enable" + report: "default" - platform: template name: "Garage Door Open 12" report: "force" From a035d844749a6c9d4f128fd0c923b0a3522e07a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:55:47 -0400 Subject: [PATCH 266/343] Bump docker/login-action from 4.3.0 to 4.4.0 in the docker-actions group (#17380) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 07a792df085..2740ca76ca2 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -96,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -154,7 +154,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d00c6523c72..b63067ab4b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} From 5738c60206b2792634ac4dfe05712d675235d0ec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:09:01 -0400 Subject: [PATCH 267/343] [nrf52] Run clang-tidy against the native sdk-nrf toolchain (#17364) --- .github/actions/cache-sdk-nrf/action.yml | 49 ++++ .github/workflows/ci.yml | 19 +- .../components/http_request/http_request.h | 2 +- esphome/components/logger/logger_zephyr.cpp | 2 +- esphome/components/nrf52/__init__.py | 11 +- esphome/components/nrf52/clang_tidy.py | 249 ++++++++++++++++++ esphome/components/nrf52/framework.py | 24 +- esphome/core/defines.h | 2 +- script/clang-tidy | 19 +- script/clang_tidy_hash.py | 2 + script/helpers_zephyr.py | 149 ++++------- tests/unit_tests/test_nrf52_framework.py | 26 +- 12 files changed, 432 insertions(+), 122 deletions(-) create mode 100644 .github/actions/cache-sdk-nrf/action.yml create mode 100644 esphome/components/nrf52/clang_tidy.py diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml new file mode 100644 index 00000000000..71c09bfe14e --- /dev/null +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -0,0 +1,49 @@ +name: Cache sdk-nrf +description: > + Resolve the pinned sdk-nrf version and cache the native sdk-nrf install + (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. + Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and, + once the component tests build natively, their batches) shares one cache. + Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have + the Python venv already restored. +inputs: + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce a complete install (e.g. a component batch + that fails mid-install), so a partial install is never written. + default: "false" +runs: + using: composite + steps: + - name: Resolve sdk-nrf and toolchain versions for cache key + # Both versions are pinned in code, not in any file that feeds the + # other cache keys, so resolve them explicitly. Keying on them means + # the cache invalidates when either is bumped (actions/cache never + # overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + version=$(python -c ' + from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION + from esphome.components.nrf52.framework import TOOLCHAIN_VERSION + print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")') + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it + # lives in the default-branch scope readable by all PRs); PRs are + # restore-only and never push multi-GB artifacts into their own scope. + - name: Cache sdk-nrf install (write on dev) + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} + - name: Cache sdk-nrf install (restore-only off dev) + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9310b45b4ab..caf6453c1b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,6 +475,8 @@ jobs: GH_TOKEN: ${{ github.token }} # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52-tidy installs sdk-nrf natively; pin it to a cacheable path. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: 2 @@ -491,7 +493,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 - pio_cache_key: tidy-zephyr + cache_sdk_nrf: true ignore_errors: false steps: @@ -527,6 +529,10 @@ jobs: with: framework: arduino + - name: Cache sdk-nrf install + if: matrix.cache_sdk_nrf + uses: ./.github/actions/cache-sdk-nrf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -805,6 +811,9 @@ jobs: # esp32 component builds use the native ESP-IDF toolchain (default), so # share the tidy jobs' install location -- the restore below lands here. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52 component builds install sdk-nrf natively; pin it to the shared + # cacheable path so the restore below lands where the build looks. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -840,6 +849,14 @@ jobs: uses: ./.github/actions/cache-esp-idf with: restore-only: true + - name: Cache sdk-nrf install (restore-only) + # Only batches whose test platforms include nrf52 need the native + # sdk-nrf install; never save -- just reuse the shared install the + # dev nrf52 tidy job cached when present. + if: matrix.batch.needs_nrf + uses: ./.github/actions/cache-sdk-nrf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 5025a5c12d5..df1bb462ab5 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -510,9 +510,9 @@ template class HttpRequestSendAction final : public Actionmax_response_buffer_size_; #ifdef USE_HTTP_REQUEST_RESPONSE if (this->capture_response_.value(x...)) { + size_t max_length = this->max_response_buffer_size_; std::string response_body; RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 240bcc57c79..b7884b702ba 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -57,7 +57,7 @@ void Logger::pre_setup() { if (this->baud_rate_ > 0) { static const struct device *uart_dev = nullptr; switch (this->uart_) { - case UART_SELECTION_UART0: + case UART_SELECTION_UART0: // NOLINT(bugprone-branch-clone) uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(uart0)); break; case UART_SELECTION_UART1: diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7c17eadd1a0..a5f2018d551 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -79,6 +79,11 @@ AUTO_LOAD = ["zephyr", "preferences"] IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) +# Default framework versions per toolchain. The sdk-nrf one also keys the CI +# sdk-nrf install cache and pins the clang-tidy project's SDK. +RECOMMENDED_PLATFORMIO_VERSION = "2.6.1-b" +RECOMMENDED_SDK_NRF_VERSION = "2.9.2" + FAKE_BOARD_MANIFEST = """ { "frameworks": [ @@ -123,7 +128,11 @@ def _resolve_toolchain(config: ConfigType) -> ConfigType: def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: - default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" + default_version = ( + RECOMMENDED_PLATFORMIO_VERSION + if CORE.using_toolchain_platformio + else RECOMMENDED_SDK_NRF_VERSION + ) config = { **config, CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version}, diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py new file mode 100644 index 00000000000..2dd4b7bd09f --- /dev/null +++ b/esphome/components/nrf52/clang_tidy.py @@ -0,0 +1,249 @@ +"""Generate clang-tidy compile commands via the native sdk-nrf toolchain. + +Produces a ``compile_commands.json`` for the nrf52/Zephyr clang-tidy +environment **without an ESPHome YAML config**, mirroring +``esphome.espidf.clang_tidy``: generate a minimal Zephyr application, run a +configure-only west build with the native sdk-nrf toolchain, and let +``script/helpers_zephyr.py`` extract idedata from the resulting compile +commands. + +* the stub app is C++ so the compile commands carry C++ flags, matching how + clang-tidy analyzes ESPHome's sources; +* ``prj.conf`` enables the Kconfig superset ESPHome components need (BT, ADC, + mcumgr, zigbee) so their include paths land in the compile commands; +* the platform defines (USE_ZEPHYR, USE_NRF52) match what a real ESPHome + nrf52 build adds via its generated project. + +``ESPHOME_ZEPHYR_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# Analyzed against the native toolchain's default SDK version +# (RECOMMENDED_SDK_NRF_VERSION), which also keys the CI install cache. +_TIDY_BOARD = "adafruit_itsybitsy_nrf52840" + +# Never compiled (the build is configure-only): the file exists only so the +# app target emits a C++ compile command to harvest flags/includes from. +_TIDY_MAIN_CPP = "int main() { return 0; }\n" + +# Kconfig superset enabling every subsystem an ESPHome nrf52 component may +# use, so the compile commands carry all of their include paths. +_TIDY_PRJ_CONF = """\ +CONFIG_CPP=y +CONFIG_STD_CPP20=y +CONFIG_REQUIRES_FULL_LIBCPP=y +CONFIG_NEWLIB_LIBC=y +CONFIG_BT=y +CONFIG_ADC=y +# posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the +# Zephyr POSIX headers clash with the libc ones under analysis +CONFIG_POSIX_API=y +#mcumgr begin +CONFIG_NET_BUF=y +CONFIG_ZCBOR=y +CONFIG_MCUMGR=y +CONFIG_MCUMGR_GRP_IMG=y +CONFIG_IMG_MANAGER=y +CONFIG_STREAM_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_FLASH=y +CONFIG_IMG_ERASE_PROGRESSIVELY=y +CONFIG_BOOTLOADER_MCUBOOT=y +CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y +CONFIG_MCUMGR_TRANSPORT_UART=y +#mcumgr end +#zigbee begin +CONFIG_ZIGBEE=y +CONFIG_CRYPTO=y +CONFIG_NVS=y +CONFIG_SETTINGS=y +#zigbee end +""" + + +def _tidy_cmakelists(library_include_dirs: str) -> str: + # The defines a real ESPHome nrf52 build puts on the app target. + # ESPHOME_LOG_LEVEL must be set up front -- otherwise log.h's ``#ifndef`` + # sets it to NONE, a macro-redefined warning across nearly every source. + return f"""\ +# Auto-generated by ESPHome (clang-tidy compile-commands project) +cmake_minimum_required(VERSION 3.20.0) +set(Zephyr_DIR "$ENV{{ZEPHYR_BASE}}/share/zephyr-package/cmake/") +find_package(Zephyr REQUIRED) +project({TIDY_PROJECT_NAME}) +target_sources(app PRIVATE main.cpp) +target_compile_definitions(app PRIVATE + USE_ZEPHYR + USE_NRF52 + ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE +) +target_include_directories(app PRIVATE +{library_include_dirs} +) +""" + + +def _parse_lib_deps(platformio_ini: Path) -> list: + """Parse the nrf52 env's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library`` (ArduinoJson, dlms_parser, ...); their headers must be + on the tidy translation unit's include path. Mirrors the pio nrf52 env's + ``lib_deps`` composition (``common.lib_deps_base`` + + ``common:idf-component-libs``). + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + tokens: list[str] = [] + for section, key in ( + ("common", "lib_deps_base"), + ("common:idf-component-libs", "lib_deps"), + ): + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + if not token or token.startswith(("${", "+<")): + continue + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + return libs + + +def _library_include_dirs(platformio_ini: Path) -> list[str]: + """Resolve the pio libraries and return their include roots.""" + from esphome.platformio.library import LibraryBackend, convert_libraries + + dirs: list[str] = [] + + def emit(component) -> None: + build = component.data.get("build", {}) + candidates = {build.get("includeDir", "include"), build.get("srcDir", "src")} + candidates.update({"src", "."}) + for candidate in sorted(candidates): + path = (component.path / candidate).resolve() + if path.is_dir(): + dirs.append(str(path)) + + backend = LibraryBackend( + platform="nordicnrf52", framework="zephyr", emit=emit, cache_key="zephyr" + ) + convert_libraries(_parse_lib_deps(platformio_ini), backend) + return sorted(set(dirs)) + + +def _setup_core(work_dir: Path) -> None: + """Point CORE at the tidy project + SDK version, without any YAML config.""" + from esphome.components.zephyr.const import KEY_ZEPHYR + import esphome.config_validation as cv + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, + ) + from esphome.core import CORE + + from . import RECOMMENDED_SDK_NRF_VERSION + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data-dir root for per-run artifacts. The + # sdk-nrf install is in the global cache dir, independent of this path. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + CORE.toolchain = Toolchain.SDK_NRF + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_NRF52 + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + RECOMMENDED_SDK_NRF_VERSION + ) + + +def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: + """Generate the tidy Zephyr project and run a configure-only west build. + + Returns the path to the generated ``compile_commands.json``. + """ + from esphome.core import EsphomeError + from esphome.framework_helpers import run_command_ok + from esphome.helpers import rmtree + + from .framework import check_and_install, get_build_env, get_build_paths + + # Surface ESPHome's INFO logs (sdk-nrf download/west update) -- they go + # through logging, which the clang-tidy script otherwise leaves at + # WARNING, so the first-run installation looks silent without this. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir) + check_and_install() + + library_include_dirs = "\n".join( + f' "{d}"' for d in _library_include_dirs(platformio_ini) + ) + source_dir = work_dir / "zephyr" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "CMakeLists.txt").write_text( + _tidy_cmakelists(library_include_dirs), encoding="utf-8" + ) + (source_dir / "main.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + (source_dir / "prj.conf").write_text(_TIDY_PRJ_CONF, encoding="utf-8") + + # Always configure from scratch: west can't pristine a dir whose CMake + # cache is stale/missing, and a configure-only run is cheap. + build_dir = work_dir / "build" + if build_dir.is_dir(): + rmtree(build_dir) + + paths = get_build_paths() + # Build only the generated-headers target (syscall_list.h, offsets.h, ...) + # on top of the configure: clang-tidy needs those headers to exist, but a + # full firmware build would be wasted work. --no-sysbuild keeps sdk-nrf + # 2.9+ from wrapping the build in a multi-image sysbuild project, which + # would nest the compile commands and hide the headers target. + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--no-sysbuild", + "-b", + _TIDY_BOARD, + "-d", + str(build_dir), + str(source_dir), + "-t", + "zephyr_generated_headers", + "--", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + ] + if not run_command_ok( + west_cmd, + env=get_build_env(), + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 clang-tidy configure failed") + + return build_dir / "compile_commands.json" diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 640aa07fbfc..fa6f7d57ade 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,3 +1,4 @@ +import hashlib import logging import os from pathlib import Path @@ -24,7 +25,7 @@ from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" -_TOOLCHAIN_VERSION = "0.17.4" +TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( @@ -132,7 +133,7 @@ def get_build_env() -> dict: env = os.environ.copy() env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") - env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake") return env @@ -158,9 +159,10 @@ def check_and_install() -> None: python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() install_venv = ( not sentinel.exists() - or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + or sentinel.read_text(encoding="utf-8") != requirements_hash ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") @@ -182,7 +184,7 @@ def check_and_install() -> None: raise EsphomeError( f"Install requirements for {version} Python environment failure" ) - sentinel.touch() + sentinel.write_text(requirements_hash, encoding="utf-8") framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" @@ -238,19 +240,17 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() - toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): - rmdir( - toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" - ) + rmdir(toolchains_dir, msg=f"Clean up {TOOLCHAIN_VERSION} toolchain environment") sysname, machine, extension = _get_toolchain_platform_info() with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", TOOLCHAIN_VERSION) download_from_mirrors( SDK_NG_MINIMAL_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, @@ -259,11 +259,11 @@ def check_and_install() -> None: ) archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) + _LOGGER.info("Downloading %s toolchain ...", TOOLCHAIN_VERSION) download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1c0138f9d11..ff4bccc6931 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -148,6 +148,7 @@ #define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR #define USE_NEXTION_WAVEFORM #define USE_NUMBER +#define USE_OTA_STATE_LISTENER #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY @@ -211,7 +212,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_PASSWORD -#define USE_OTA_STATE_LISTENER #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE #define USE_WIFI diff --git a/script/clang-tidy b/script/clang-tidy index 1416b9b3329..7df46cb2d2a 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -145,14 +145,16 @@ def clang_options(idedata): # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) - # add toolchain include directories using -isystem to suppress their errors + # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use toolchain_dir = os.path.normpath(f"{idedata['cxx_path']}/../../") + toolchain_includes = [] for directory in idedata["includes"]["toolchain"]: if directory.startswith(toolchain_dir) and "picolibc" not in directory: - cmd.extend(["-isystem", directory]) + toolchain_includes.extend(["-isystem", directory]) - # add library include directories using -isystem to suppress their errors + # library include directories, using -isystem to suppress their errors + build_includes = [] for directory in list(idedata["includes"]["build"]): # skip our own directories, we add those later if ( @@ -166,7 +168,16 @@ def clang_options(idedata): ) or (directory.startswith(f"{root_path}") and "/.pio/" in directory) ): - cmd.extend(["-isystem", directory]) + build_includes.extend(["-isystem", directory]) + + if "zephyr" in triplet: + # Zephyr's POSIX layer shadows libc headers (sys/select.h, ...) with + # coherently-guarded versions; the real build searches the Zephyr + # include dirs before the toolchain's, and the shadowed headers clash + # (e.g. newlib's sigset_t vs Zephyr's) in the opposite order. + cmd.extend(build_includes + toolchain_includes) + else: + cmd.extend(toolchain_includes + build_includes) # add the esphome include directory using -I cmd.extend(["-I", root_path]) diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py index 00bcaf45b01..57ca90711cb 100644 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -21,6 +21,8 @@ CLANG_TIDY_GLOBAL_FILES = ( "platformio.ini", "requirements_dev.txt", "esphome/idf_component.yml", + "esphome/components/esp32/__init__.py", + "esphome/components/nrf52/__init__.py", ) # sdkconfig.defaults and per-target sdkconfig.defaults. files flip the diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 66ef6ffc98b..c26ad7f2cd2 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -1,59 +1,32 @@ +"""Load clang-tidy idedata for the nrf52/Zephyr environment. + +The compile commands come from a configure-only build of a minimal Zephyr +project using the native sdk-nrf toolchain (see +``esphome.components.nrf52.clang_tidy``); this module extracts the include +paths, defines and compiler flags clang-tidy needs from them. +""" + import json +import os from pathlib import Path import re +import shlex import subprocess def load_idedata(environment, temp_folder, platformio_ini): - build_environment = environment.replace("-tidy", "") - build_dir = Path(temp_folder) / f"build-{build_environment}" - Path(build_dir).mkdir(exist_ok=True) - Path(build_dir / "platformio.ini").write_text( - Path(platformio_ini).read_text(encoding="utf-8"), encoding="utf-8" - ) - esphome_dir = Path(build_dir / "esphome") - esphome_dir.mkdir(exist_ok=True) - Path(esphome_dir / "main.cpp").write_text( - """ -#include -int main() { return 0;} -extern "C" void zboss_signal_handler() {}; -""", - encoding="utf-8", - ) - zephyr_dir = Path(build_dir / "zephyr") - zephyr_dir.mkdir(exist_ok=True) - Path(zephyr_dir / "prj.conf").write_text( - """ -CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y -CONFIG_ADC=y -#mcumgr begin -CONFIG_NET_BUF=y -CONFIG_ZCBOR=y -CONFIG_MCUMGR=y -CONFIG_MCUMGR_GRP_IMG=y -CONFIG_IMG_MANAGER=y -CONFIG_STREAM_FLASH=y -CONFIG_FLASH_MAP=y -CONFIG_FLASH=y -CONFIG_IMG_ERASE_PROGRESSIVELY=y -CONFIG_BOOTLOADER_MCUBOOT=y -CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y -CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin -CONFIG_ZIGBEE=y -CONFIG_CRYPTO=y -CONFIG_NVS=y -CONFIG_SETTINGS=y -#zigbee end -""", - encoding="utf-8", - ) - subprocess.run(["pio", "run", "-e", build_environment, "-d", build_dir], check=True) + if explicit := os.environ.get("ESPHOME_ZEPHYR_COMPILE_COMMANDS"): + compile_commands_path = Path(explicit) + else: + from esphome.components.nrf52.clang_tidy import generate_compile_commands + + work_dir = (Path(temp_folder) / f"zephyr-{environment}").resolve() + compile_commands_path = generate_compile_commands( + work_dir, Path(platformio_ini) + ) + + if not compile_commands_path.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands_path}") def extract_include_paths(command): include_paths = [] @@ -62,7 +35,7 @@ CONFIG_SETTINGS=y split_strings = re.split( r"\s*-\s*(?:I|isystem)", list(filter(lambda x: x, match))[0] ) - include_paths.append(split_strings[1]) + include_paths.append(split_strings[1].strip()) return include_paths def extract_defines(command): @@ -74,15 +47,6 @@ CONFIG_SETTINGS=y if not any(match.startswith(prefix) for prefix in ignore_prefixes) ] - def find_cxx_path(commands): - for entry in commands: - command = entry["command"] - cxx_path = command.split()[0] - if not cxx_path.endswith("++"): - continue - return cxx_path - return None - def get_builtin_include_paths(compiler): result = subprocess.run( [compiler, "-E", "-x", "c++", "-", "-v"], @@ -105,47 +69,48 @@ CONFIG_SETTINGS=y return include_paths def extract_cxx_flags(command): - # Extracts CXXFLAGS from the command string, excluding includes and defines. + # Extracts CXXFLAGS from the command string, excluding includes and + # defines. Anchored per token: a substring match would extract a bogus + # "-format-zero-length" from -Wno-format-zero-length. flag_pattern = re.compile( - r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" + r"^(-O[0-3s]|-g|-std=.+|-Wall|-Wextra|-Werror|--.+|-f.+|-m.+|-imacros.+)$" ) - return [ - match.replace("-imacros ", "-imacros") - for match in flag_pattern.findall(command) - ] + flags = [] + tokens = shlex.split(command) + for i, token in enumerate(tokens): + if token == "-imacros" and i + 1 < len(tokens): + flags.append(f"-imacros{tokens[i + 1]}") + elif flag_pattern.match(token): + flags.append(token) + return flags def transform_to_idedata_format(compile_commands): - cxx_path = find_cxx_path(compile_commands) - idedata = { + # Use only the tidy app TU (main.cpp): as the app target, its compile + # command already carries the full Zephyr include set. Unioning every + # TU instead would drag in per-library internal include dirs (e.g. the + # Zephyr POSIX shim, whose signal.h redefines newlib's sigset_t) that + # no ESPHome source compiles against. + entry = next( + (e for e in compile_commands if e["file"].endswith("main.cpp")), None + ) + if entry is None: + raise RuntimeError("tidy main.cpp not found in compile_commands.json") + command = entry["command"] + # Find the compiler by name: the command may be prefixed with a + # launcher (Zephyr auto-enables ccache when present). + cxx_path = next((t for t in shlex.split(command) if t.endswith("++")), None) + if cxx_path is None: + raise RuntimeError(f"no C++ compiler in compile command: {command}") + + return { "includes": { "toolchain": get_builtin_include_paths(cxx_path), - "build": set(), + "build": extract_include_paths(command), }, - "defines": set(), + "defines": extract_defines(command), "cxx_path": cxx_path, - "cxx_flags": set(), + "cxx_flags": extract_cxx_flags(command), } - for entry in compile_commands: - command = entry["command"] - exec = command.split()[0] - if exec != cxx_path: - continue - - idedata["includes"]["build"].update(extract_include_paths(command)) - idedata["defines"].update(extract_defines(command)) - idedata["cxx_flags"].update(extract_cxx_flags(command)) - - # Convert sets to lists for JSON serialization - idedata["includes"]["build"] = list(idedata["includes"]["build"]) - idedata["defines"] = list(idedata["defines"]) - idedata["cxx_flags"] = list(idedata["cxx_flags"]) - - return idedata - - compile_commands = json.loads( - Path( - build_dir / ".pio" / "build" / build_environment / "compile_commands.json" - ).read_text(encoding="utf-8") - ) + compile_commands = json.loads(compile_commands_path.read_text(encoding="utf-8")) return transform_to_idedata_format(compile_commands) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 2b3d1f6db8a..bb5bc8c064c 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,5 +1,6 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import hashlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -7,7 +8,8 @@ from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( - _TOOLCHAIN_VERSION, + _REQUIREMENTS, + TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, get_sdk_nrf_tools_path, @@ -71,7 +73,7 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" - toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + toolchain_dir = tools / "toolchains" / TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) zephyr_scripts = framework / "zephyr" / "scripts" @@ -113,6 +115,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_venv_ready(python_env: Path) -> None: + """Write the venv sentinel with the current requirements hash.""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + + class TestCheckAndInstall: def test_all_installed_skips_all_steps( self, @@ -120,7 +128,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -157,7 +165,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv ready but framework missing → skip venv creation, run SDK init+update.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) check_and_install() @@ -173,7 +181,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() @@ -202,7 +210,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west init raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) mock_nrf52_ops.run_command_ok.return_value = False with pytest.raises(EsphomeError, match="Can't initialize"): @@ -214,7 +222,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west update raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) # init succeeds, update fails mock_nrf52_ops.run_command_ok.side_effect = [True, False] @@ -227,7 +235,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.framework / ".ready").touch() with patch( @@ -238,7 +246,7 @@ class TestCheckAndInstall: args, _ = mock_nrf52_ops.download_from_mirrors.call_args substitutions = args[1] - assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["VERSION"] == TOOLCHAIN_VERSION assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" From e94fcda8b7df65797274a97527dfcc0b89533485 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Sat, 4 Jul 2026 02:13:22 +0000 Subject: [PATCH 268/343] [cst328] Touch screen (Waveshare ESP32-S3-Touch-LCD-2.8) (#8011) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/cst328/__init__.py | 6 + .../cst328/binary_sensor/__init__.py | 28 +++ .../cst328/binary_sensor/cst328_button.cpp | 16 ++ .../cst328/binary_sensor/cst328_button.h | 20 +++ .../components/cst328/touchscreen/__init__.py | 38 ++++ .../cst328/touchscreen/cst328_touchscreen.cpp | 168 ++++++++++++++++++ .../cst328/touchscreen/cst328_touchscreen.h | 61 +++++++ tests/components/cst328/common.yaml | 22 +++ tests/components/cst328/test.esp32-idf.yaml | 8 + 10 files changed, 368 insertions(+) create mode 100644 esphome/components/cst328/__init__.py create mode 100644 esphome/components/cst328/binary_sensor/__init__.py create mode 100644 esphome/components/cst328/binary_sensor/cst328_button.cpp create mode 100644 esphome/components/cst328/binary_sensor/cst328_button.h create mode 100644 esphome/components/cst328/touchscreen/__init__.py create mode 100644 esphome/components/cst328/touchscreen/cst328_touchscreen.cpp create mode 100644 esphome/components/cst328/touchscreen/cst328_touchscreen.h create mode 100644 tests/components/cst328/common.yaml create mode 100644 tests/components/cst328/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b222c442149..571f8492f15 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,6 +122,7 @@ esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow +esphome/components/cst328/* @latonita esphome/components/cst816/* @clydebarrow esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz diff --git a/esphome/components/cst328/__init__.py b/esphome/components/cst328/__init__.py new file mode 100644 index 00000000000..374df648982 --- /dev/null +++ b/esphome/components/cst328/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] + +cst328_ns = cg.esphome_ns.namespace("cst328") diff --git a/esphome/components/cst328/binary_sensor/__init__.py b/esphome/components/cst328/binary_sensor/__init__.py new file mode 100644 index 00000000000..6d881cc6c1c --- /dev/null +++ b/esphome/components/cst328/binary_sensor/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import cst328_ns +from ..touchscreen import CST328ButtonListener, CST328Touchscreen + +CONF_CST328_ID = "cst328_id" + +CST328Button = cst328_ns.class_( + "CST328Button", + binary_sensor.BinarySensor, + cg.Component, + CST328ButtonListener, + cg.Parented.template(CST328Touchscreen), +) + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST328Button).extend( + { + cv.GenerateID(CONF_CST328_ID): cv.use_id(CST328Touchscreen), + } +) + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_CST328_ID]) diff --git a/esphome/components/cst328/binary_sensor/cst328_button.cpp b/esphome/components/cst328/binary_sensor/cst328_button.cpp new file mode 100644 index 00000000000..b58f4b4b9f6 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.cpp @@ -0,0 +1,16 @@ +#include "cst328_button.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { +static const char *const TAG = "cst328.binary_sensor"; + +void CST328Button::setup() { + this->parent_->register_button_listener(this); + this->publish_initial_state(false); +} + +void CST328Button::dump_config() { LOG_BINARY_SENSOR("", "CST328 Button", this); } + +void CST328Button::update_button(bool state) { this->publish_state(state); } + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/binary_sensor/cst328_button.h b/esphome/components/cst328/binary_sensor/cst328_button.h new file mode 100644 index 00000000000..a9ed4785e58 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "../touchscreen/cst328_touchscreen.h" + +namespace esphome::cst328 { + +class CST328Button : public binary_sensor::BinarySensor, + public Component, + public CST328ButtonListener, + public Parented { + public: + void setup() override; + void dump_config() override; + void update_button(bool state) override; +}; + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py new file mode 100644 index 00000000000..18c00bb6c52 --- /dev/null +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -0,0 +1,38 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst328_ns + +CST328Touchscreen = cst328_ns.class_( + "CST328Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CST328ButtonListener = cst328_ns.class_("CST328ButtonListener") + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST328Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x1A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp new file mode 100644 index 00000000000..5e1a2ebf728 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp @@ -0,0 +1,168 @@ +#include "cst328_touchscreen.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { + +static const char *const TAG = "cst328.touchscreen"; + +static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet +static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less +static const uint16_t CST328_FW_CRC = 0xCACA; // Expected firmware CRC value +static const uint8_t CST328_SYNC_BYTE = 0xAB; // Sync byte used in communication + +static const uint8_t ZERO_BYTE = 0; + +#define I2C_WARN_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGW(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->status_set_warning(format); \ + } \ + } while (0) + +#define I2C_FAIL_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGE(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->mark_failed(); \ + return; \ + } \ + } while (0) + +void CST328Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up CST328 Touchscreen..."); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_BEFORE_RESET_TIMEOUT, [this] { this->reset_device_(); }); + } else { + this->continue_setup_(); + } +} + +void CST328Touchscreen::reset_device_() { + this->reset_pin_->digital_write(false); + delay(5); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); }); +} + +void CST328Touchscreen::continue_setup_() { + ESP_LOGV(TAG, "Continuing CST328 setup..."); + + uint8_t data_byte{0}; + uint8_t buf[24]{}; + + I2C_FAIL_ON_ERROR(this->write_register16(CST_WM_DEBUG_INFO, buf, 0), TAG, "Failed to enter debug/info mode"); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_CRC_AND_BOOT_TIME, buf, 4), TAG, + "Failed to read FW CRC and boot time"); + + uint16_t fw_crc = buf[2] + (buf[3] << 8); + if (fw_crc != CST328_FW_CRC) { + ESP_LOGE(TAG, "Error: Firmware CRC mismatch, expected 0x%04X but got 0x%04X", CST328_FW_CRC, fw_crc); + this->mark_failed(); + return; + } + + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_CHIP_TYPE_AND_PROJECT_ID, buf, 4), TAG, + "Failed to read chip and project ID"); + + this->chip_id_ = buf[2] + (buf[3] << 8); + this->project_id_ = buf[0] + (buf[1] << 8); + ESP_LOGD(TAG, "Chip ID %X, project ID %X", this->chip_id_, this->project_id_); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_REVISION, buf, 4), TAG, "Failed to read FW version"); + + this->fw_ver_major_ = buf[3]; + this->fw_ver_minor_ = buf[2]; + this->fw_build_ = buf[0] + (buf[1] << 8); + ESP_LOGV(TAG, "FW version %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + + if (i2c::ERROR_OK == this->read_register16(CST_REG_X_Y_RESOLUTION, buf, 4)) { + this->x_raw_max_ = buf[0] + (buf[1] << 8); + this->y_raw_max_ = buf[2] + (buf[3] << 8); + } else { + this->x_raw_max_ = this->display_->get_native_width(); + this->y_raw_max_ = this->display_->get_native_height(); + } + + I2C_WARN_ON_ERROR(this->write_register16(CST_WM_NORMAL, buf, 0), TAG, "Failed to enter normal mode"); + I2C_WARN_ON_ERROR(this->read_register16(CST_REG_TOUCH_INFORMATION, &data_byte, 1), TAG, "Failed to read sync"); + I2C_WARN_ON_ERROR(this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1), TAG, + "Failed to write sync"); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + this->setup_complete_ = true; + ESP_LOGV(TAG, "CST328 setup complete"); +} + +void CST328Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "CST328 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + ESP_LOGCONFIG(TAG, " Chip ID: 0x%04X, Project ID: 0x%04X", this->chip_id_, this->project_id_); + ESP_LOGCONFIG(TAG, " FW version: %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + ESP_LOGCONFIG(TAG, " X/Y resolution: %d/%d", this->x_raw_max_, this->y_raw_max_); +} + +void CST328Touchscreen::update_button_state_(bool state) { + if (this->button_touched_ == state) { + return; + } + this->button_touched_ = state; + for (auto *listener : this->button_listeners_) { + listener->update_button(state); + } +} + +void CST328Touchscreen::update_touches() { + if (!this->setup_complete_) { + this->skip_update_ = true; + return; + } + + uint8_t touch_data[CST328_TOUCH_DATA_SIZE]; + + this->status_clear_warning(); + + if (i2c::ERROR_OK != this->read_register16(CST_REG_TOUCH_INFORMATION, touch_data, CST328_TOUCH_DATA_SIZE)) { + ESP_LOGW(TAG, "Failed to read touch data"); + this->status_set_warning(); + this->skip_update_ = true; + return; + } + + uint8_t touch_cnt = touch_data[CST_REG_FINGER_COUNT_IDX] & 0x0F; + if (touch_cnt == 0 || touch_cnt > CST328_TOUCH_MAX_POINTS) { + this->update_button_state_(false); + } else { + this->update_button_state_(true); + + uint8_t data_idx = 0; + for (uint8_t i = 0; i < touch_cnt; i++) { + uint8_t id = touch_data[data_idx] >> 4; + int16_t x = (touch_data[data_idx + 1] << 4) | ((touch_data[data_idx + 3] >> 4) & 0x0F); + int16_t y = (touch_data[data_idx + 2] << 4) | (touch_data[data_idx + 3] & 0x0F); + int16_t z = touch_data[data_idx + 4]; + + this->add_raw_touch_position_(id, x, y, z); + data_idx += (i == 0) ? 7 : 5; + } + } + + bool cleanup_error = false; + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_FINGER_NUMBER, &ZERO_BYTE, 1)); + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1)); + + if (cleanup_error) { + ESP_LOGW(TAG, "Failed to clean up touch registers"); + } +} + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.h b/esphome/components/cst328/touchscreen/cst328_touchscreen.h new file mode 100644 index 00000000000..234ec6eee07 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::cst328 { + +static const uint8_t CST328_TOUCH_MAX_POINTS = 5; +static const uint8_t CST328_TOUCH_DATA_SIZE = CST328_TOUCH_MAX_POINTS * 5 + 2; + +static const uint16_t CST_REG_TOUCH_INFORMATION = 0xD000; +static const uint16_t CST_REG_TOUCH_FINGER_NUMBER = 0xD005; + +static const uint16_t CST_REG_FINGER_COUNT_IDX = CST_REG_TOUCH_FINGER_NUMBER - CST_REG_TOUCH_INFORMATION; + +static const uint16_t CST_REG_X_Y_RESOLUTION = 0xD1F8; +static const uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = 0xD1FC; +static const uint16_t CST_REG_CHIP_TYPE_AND_PROJECT_ID = 0xD204; +static const uint16_t CST_REG_FW_REVISION = 0xD208; + +static const uint16_t CST_WM_DEBUG_INFO = 0xD101; +static const uint16_t CST_WM_NORMAL = 0xD109; + +class CST328ButtonListener { + public: + virtual void update_button(bool state) = 0; +}; + +class CST328Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void register_button_listener(CST328ButtonListener *listener) { this->button_listeners_.push_back(listener); } + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void reset_device_(); + void continue_setup_(); + void update_button_state_(bool state); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + + std::vector button_listeners_; + bool button_touched_{}; + + uint16_t chip_id_{}; + uint16_t project_id_{}; + uint8_t fw_ver_major_{}; + uint8_t fw_ver_minor_{}; + uint16_t fw_build_{}; + + bool setup_complete_{}; +}; + +} // namespace esphome::cst328 diff --git a/tests/components/cst328/common.yaml b/tests/components/cst328/common.yaml new file mode 100644 index 00000000000..286dbf587fe --- /dev/null +++ b/tests/components/cst328/common.yaml @@ -0,0 +1,22 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: cst328_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: cst328_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: cst328 + i2c_id: i2c_bus + id: cst328_touchscreen + display: cst328_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} + +binary_sensor: + - platform: cst328 + id: touch_key_cst328 diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml new file mode 100644 index 00000000000..3dc184e3286 --- /dev/null +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + display_reset_pin: "4" + interrupt_pin: "20" + reset_pin: "21" + +packages: + - !include ../../test_build_components/common/i2c/esp32-idf.yaml + - !include common.yaml From 787805253393551a4a5cfa09a4e351a8cc548ed8 Mon Sep 17 00:00:00 2001 From: Citric Li <37475446+limengdu@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:50:54 +0800 Subject: [PATCH 269/343] [epaper_spi] Add T133A01 6-color e-paper driver for reTerminal E1004 (#16706) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 2 + .../epaper_spi/epaper_spi_t133a01.cpp | 367 ++++++++++++++++++ .../epaper_spi/epaper_spi_t133a01.h | 77 ++++ .../components/epaper_spi/models/__init__.py | 23 ++ .../components/epaper_spi/models/t133a01.py | 71 ++++ tests/component_tests/epaper_spi/test_init.py | 8 + .../epaper_spi/test.esp32-s3-idf.yaml | 8 + .../validate-e1004.esp32-s3-idf.yaml | 38 ++ 8 files changed, 594 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_t133a01.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_t133a01.h create mode 100644 esphome/components/epaper_spi/models/t133a01.py create mode 100644 tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index ce28fb0d67e..0b82850f1ef 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -112,6 +112,7 @@ def model_schema(config): cv.positive_time_period_milliseconds, cv.Range(max=core.TimePeriod(milliseconds=500)), ), + **model.get_config_options(), } ) @@ -198,6 +199,7 @@ async def to_code(config): ) await display.register_display(var, config) + config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp new file mode 100644 index 00000000000..5735333761b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -0,0 +1,367 @@ +#include "epaper_spi_t133a01.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.t133a01"; + +// Color indices used in the 4bpp buffer (sprite-side) +// These MUST match the Arduino GFX TFT_eSPI.h color definitions and +// the remap_color()/COLOR_GET mapping: +// 0x0F=BLACK, 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE +static constexpr uint8_t T133A01_BLACK = 0x0F; +static constexpr uint8_t T133A01_WHITE = 0x00; +static constexpr uint8_t T133A01_GREEN = 0x02; +static constexpr uint8_t T133A01_RED = 0x06; +static constexpr uint8_t T133A01_YELLOW = 0x0B; +static constexpr uint8_t T133A01_BLUE = 0x0D; + +// T133A01 register addresses +static constexpr uint8_t R00_PSR = 0x00; +static constexpr uint8_t R01_PWR = 0x01; +static constexpr uint8_t R02_POF = 0x02; +static constexpr uint8_t R04_PON = 0x04; +static constexpr uint8_t R05_BTST_N = 0x05; +static constexpr uint8_t R06_BTST_P = 0x06; +static constexpr uint8_t R10_DTM = 0x10; +static constexpr uint8_t R12_DRF = 0x12; +static constexpr uint8_t R50_CDI = 0x50; +static constexpr uint8_t R61_TRES = 0x61; +static constexpr uint8_t RA5_DCDC = 0xA5; +static constexpr uint8_t RE0_CCSET = 0xE0; +static constexpr uint8_t RE3_PWS = 0xE3; + +/** + * COLOR_GET remap table from T133A01_Defines.h. + * Translates 4bpp sprite color index to the hardware pixel encoding. + * Sprite: 0x0F=BLACK 0x00=WHITE 0x02=GREEN 0x06=RED 0x0B=YELLOW 0x0D=BLUE + * HW: 0x00=BLACK 0x01=WHITE 0x06=GREEN 0x03=RED 0x02=YELLOW 0x05=BLUE + */ +uint8_t EPaperT133A01::remap_color(uint8_t index) { + switch (index & 0x0F) { + case 0x0F: + return 0x00; // Black + case 0x00: + return 0x01; // White + case 0x02: + return 0x06; // Green + case 0x06: + return 0x03; // Red + case 0x0B: + return 0x02; // Yellow + case 0x0D: + return 0x05; // Blue + default: + return 0x01; // White fallback + } +} + +/** + * Map an ESPHome Color to a 4-bit sprite color index. + * Index values match the Arduino GFX TFT_eSPI color definitions: + * 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE, 0x0F=BLACK + */ +uint8_t EPaperT133A01::color_to_index(Color color) { + unsigned char max_rgb = std::max({color.r, color.g, color.b}); + unsigned char min_rgb = std::min({color.r, color.g, color.b}); + + // Check for grayscale + if ((max_rgb - min_rgb) < 50) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return T133A01_WHITE; + } + return T133A01_BLACK; + } + + bool r_on = (color.r > 128); + bool g_on = (color.g > 128); + bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) + return T133A01_YELLOW; + if (r_on && !g_on && !b_on) + return T133A01_RED; + if (!r_on && g_on && !b_on) + return T133A01_GREEN; + if (!r_on && !g_on && b_on) + return T133A01_BLUE; + // Handle mixed colors: map to nearest primary + if (!r_on && g_on && b_on) + return T133A01_GREEN; // Cyan -> Green + if (r_on && !g_on) + return T133A01_RED; // Magenta -> Red + if (r_on) + return T133A01_WHITE; + return T133A01_BLACK; +} + +void EPaperT133A01::setup() { + // Base setup initialises the buffer, the standard pins and the SPI bus. + EPaperBase::setup(); + + // Both chip-selects are driven directly by this driver (the dual-CS + // protocol needs CS held HIGH while CS1 receives data, which the SPI + // bus cannot do). Start both deselected (HIGH). + this->cs_pin_->setup(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->setup(); + this->cs1_pin_->digital_write(true); +} + +bool EPaperT133A01::reset() { + for (auto *enable_pin : this->enable_pins_) { + enable_pin->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + if (this->state_ == EPaperState::RESET) { + this->reset_pin_->digital_write(false); + return false; + } + this->reset_pin_->digital_write(true); + } + return true; +} + +/** + * Initialise the T133A01 display. + * + * The init sequence uses a mix of CS and CS1 commands as per the Arduino driver. + * The base class init_sequence is NOT used for T133A01 because the dual-CS + * protocol requires per-command routing. + */ +bool EPaperT133A01::initialise(bool partial) { + // Init sequence mirrors the Arduino GFX library's EPD_INIT() macro + // (T133A01_Defines.h). Commands routed to CS only leave CS1 deselected; + // commands routed to both controllers assert CS and CS1 together. + + // 0x74 - panel config (CS only) + this->write_command_(0x74, {0x00, 0x0C, 0x0C, 0xD9, 0xDD, 0xDD, 0x15, 0x15, 0x55}, true, false); + delay(10); + + // 0xF0 - panel config (CS + CS1) + this->write_command_(0xF0, {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10}, true, true); + delay(10); + + // PSR - Panel Setting Register (CS + CS1) + this->write_command_(0x00, {0xDF, 0x69}, true, true); + delay(10); + + // DCDC (CS only) + this->write_command_(RA5_DCDC, {0x44, 0x54, 0x00}, true, false); + delay(10); + + // CDI (CS + CS1) + this->write_command_(R50_CDI, {0x37}, true, true); + delay(10); + + // 0x60 (CS + CS1) + this->write_command_(0x60, {0x03, 0x03}, true, true); + delay(10); + + // 0x86 (CS + CS1) + this->write_command_(0x86, {0x10}, true, true); + delay(10); + + // PWS - Phase Width Setting (CS + CS1) + this->write_command_(RE3_PWS, {0x22}, true, true); + delay(10); + + // TRES - Resolution Setting (CS + CS1). + // With width=1200, height=1600: first word = width = 1200, second word = height/2 = 800. + this->write_command_(R61_TRES, + {(uint8_t) (this->width_ >> 8), (uint8_t) (this->width_ & 0xFF), + (uint8_t) ((this->height_ / 2) >> 8), (uint8_t) ((this->height_ / 2) & 0xFF)}, + true, true); + delay(10); + + // PWR - Power Setting (CS only) + this->write_command_(R01_PWR, {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38}, true, false); + delay(10); + + // 0xB6 (CS only) + this->write_command_(0xB6, {0x07}, true, false); + delay(10); + + // BTST_P (CS only) + this->write_command_(R06_BTST_P, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB7 (CS only) + this->write_command_(0xB7, {0x01}, true, false); + delay(10); + + // BTST_N (CS only) + this->write_command_(R05_BTST_N, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB0 (CS only) + this->write_command_(0xB0, {0x01}, true, false); + delay(10); + + // 0xB1 (CS only) + this->write_command_(0xB1, {0x02}, true, false); + delay(10); + + return true; +} + +void EPaperT133A01::write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1) { + ESP_LOGV(TAG, "Command: 0x%02X, Length: %u, CS: %d, CS1: %d", command, (unsigned) length, use_cs, use_cs1); + // Chip-selects are active-low: assert the requested controllers. + this->cs_pin_->digital_write(!use_cs); + this->cs1_pin_->digital_write(!use_cs1); + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(command); + if (length > 0) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + +void EPaperT133A01::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + auto pixel_color = color_to_index(color); + this->buffer_.fill(pixel_color + (pixel_color << 4)); +} + +void EPaperT133A01::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = color_to_index(color); + uint32_t pixel_position = x + y * this->get_width_internal(); + uint32_t byte_position = pixel_position / 2; + auto original = this->buffer_[byte_position]; + if ((pixel_position & 1) != 0) { + this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; + } else { + this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); + } +} + +void EPaperT133A01::power_on() { + ESP_LOGV(TAG, "Power on"); + this->write_command_(R04_PON, true, true); +} + +void EPaperT133A01::power_off() { + ESP_LOGV(TAG, "Power off"); + this->write_command_(R02_POF, {0x00}, true, true); +} + +void EPaperT133A01::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + // Display Refresh + this->write_command_(R12_DRF, {0x01}, true, true); +} + +void EPaperT133A01::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->write_command_(0x07, {0xA5}, true, true); +} + +bool HOT EPaperT133A01::transfer_data() { + const uint32_t start_time = millis(); + const uint16_t bytes_per_half_row = this->width_ / 4; + const uint16_t total_rows = this->height_; + const uint16_t bytes_per_row = this->width_ / 2; + uint8_t line_data[400] = {}; + + size_t half = this->current_data_index_; + + // --- CCSET: select color set before data transfer (CS + CS1) --- + if (half == 0) { + this->write_command_(RE0_CCSET, {0x01}, true, true); + this->wait_for_idle_(true); + delay(10); + } + + // --- CS phase: left half of each row via CS --- + // T133A01 requires CS to stay LOW for the ENTIRE DTM data stream. + // Toggling CS between chunks resets the controller's data pointer, + // causing only the last chunk to be retained. Keep CS asserted + // across timeout boundaries by NOT deselecting on yield. + if (half < total_rows) { + if (half == 0) { + this->cs_pin_->digital_write(false); // select CS + this->cs1_pin_->digital_write(true); // deselect CS1 + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows) { + size_t buf_offset = half * bytes_per_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS phase done"); + this->disable(); + this->cs_pin_->digital_write(true); // deselect CS + } + + // --- CS1 phase: right half of each row via CS1 --- + // Same continuous-transaction requirement as the CS phase. + // CS is held HIGH so only CS1 receives the data. + if (half >= total_rows && half < total_rows * 2) { + size_t cs1_row = half - total_rows; + + if (cs1_row == 0) { + this->cs_pin_->digital_write(true); // deselect CS + this->cs1_pin_->digital_write(false); // select CS1 + this->enable(); + this->dc_pin_->digital_write(false); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows * 2) { + size_t row = half - total_rows; + size_t buf_offset = row * bytes_per_row + bytes_per_half_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS1 phase done"); + this->disable(); + this->cs1_pin_->digital_write(true); // deselect CS1 + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperT133A01::dump_config() { + EPaperBase::dump_config(); + LOG_PIN(" CS Pin: ", this->cs_pin_); + LOG_PIN(" CS1 Pin: ", this->cs1_pin_); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h new file mode 100644 index 00000000000..0d07fc03ae8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -0,0 +1,77 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * T133A01-based 6-color e-paper display driver. + * + * The T133A01 controller uses a dual-CS SPI architecture: + * - CS (primary): Controls the first half of pixel data transfer + * - CS1 (secondary): Controls panel commands (init, power, refresh) and + * the second half of pixel data transfer + * + * Color depth: 4 bits per pixel, supporting 6 colors: + * White, Green, Red, Yellow, Blue, Black + * + * Buffer layout: 2 pixels per byte (4bpp packed), total buffer size + * is width * height / 2 bytes. + */ +class EPaperT133A01 : public EPaperBase { + public: + EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp + } + + void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { + this->cs_pin_ = cs; + this->cs1_pin_ = cs1; + } + + void fill(Color color) override; + + void setup() override; + void dump_config() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + bool reset() override; + bool initialise(bool partial) override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + /** + * Send a command (and optional data) selecting one or both controllers. + * Both chip-selects are active-low and managed directly by this driver. + * @param command The command byte to send + * @param data Optional pointer to data bytes to send after the command + * @param length Number of data bytes to send after the command + * @param use_cs assert CS (left controller) for this transaction + * @param use_cs1 assert CS1 (right controller) for this transaction + */ + void write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1); + void write_command_(uint8_t command, std::initializer_list data, bool use_cs, bool use_cs1) { + this->write_command_(command, data.begin(), data.size(), use_cs, use_cs1); + } + void write_command_(uint8_t command, bool use_cs, bool use_cs1) { + this->write_command_(command, nullptr, 0, use_cs, use_cs1); + } + + /// Convert Color to 4-bit T133A01 color index + static uint8_t color_to_index(Color color); + + /// Apply COLOR_GET remap table to translate sprite indices to hardware values + static uint8_t remap_color(uint8_t index); + + GPIOPin *cs_pin_{nullptr}; + GPIOPin *cs1_pin_{nullptr}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 3fcf3217ecb..2360b090ffb 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,11 +2,15 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj class EpaperModel: models: dict[str, Self] = {} + # Whether the driver manages chip-select itself instead of via the SPI bus. + manages_cs: bool = False + def __init__( self, name: str, @@ -35,6 +39,25 @@ class EpaperModel: def get_constructor_args(self, config) -> tuple: return () + def get_config_options(self) -> dict: + """ + Return model-specific configuration schema options. + The base implementation adds nothing; specific models override this to + declare extra options without cluttering the shared schema. + :return: A mapping suitable for cv.Schema.extend() + """ + return {} + + async def to_code(self, var: MockObj, config: dict) -> dict: + """ + Generate model-specific code for the options added by add_options(). + The base implementation does nothing; specific models override this. + The config can be updated in place to add or remove options. + :param var: The component variable + :param config: The validated configuration + """ + return config + def get_dimensions(self, config) -> tuple[int, int]: if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is diff --git a/esphome/components/epaper_spi/models/t133a01.py b/esphome/components/epaper_spi/models/t133a01.py new file mode 100644 index 00000000000..0a57b957953 --- /dev/null +++ b/esphome/components/epaper_spi/models/t133a01.py @@ -0,0 +1,71 @@ +"""T133A01-based e-paper displays. + +The T133A01 is a 6-color e-paper controller IC that drives large panels +(1200x1600 portrait). It uses a dual-CS SPI architecture where CS +controls one half of the pixel data and CS1 controls the other half, +as well as panel-level commands (power on, refresh, power off). + +Supported models: +- Seeed-reTerminal-E1004: 1200x1600 pixels, 6-color (T133A01 panel) +""" + +from esphome import pins +import esphome.codegen as cg +from esphome.const import CONF_CS_PIN +from esphome.cpp_generator import MockObj + +from . import EpaperModel + +CONF_CS1_PIN = "cs1_pin" + + +class T133A01Model(EpaperModel): + """EpaperModel subclass for T133A01-based 6-color e-paper displays.""" + + # The driver drives CS and CS1 directly for the dual-CS protocol. + manages_cs = True + + def __init__(self, name, class_name="EPaperT133A01", **defaults): + super().__init__(name, class_name, **defaults) + + def get_config_options(self) -> dict: + # CS1 is the second chip-select required by the dual-CS architecture. + # fallback=None makes it required unless the model provides a default. + return { + self.option(CONF_CS1_PIN, fallback=None): pins.gpio_output_pin_schema, + } + + async def to_code(self, var: MockObj, config: dict) -> dict: + cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) + cs1 = await cg.gpio_pin_expression(config[CONF_CS1_PIN]) + cg.add(var.set_cs_pins(cs, cs1)) + # Remove CS and CS1 from the config so that the base class doesn't try to handle them. + return {k: v for k, v in config.items() if k not in (CONF_CS_PIN, CONF_CS1_PIN)} + + +t133a01_base = T133A01Model( + "t133a01", + minimum_update_interval="30s", + data_rate="10MHz", +) + +# Seeed reTerminal E1004 - 13.3" 6-color e-paper (1200x1600, T133A01) +# Portrait orientation (1200 wide × 1600 tall), matching the Arduino +# Setup523 defines TFT_WIDTH=1200, TFT_HEIGHT=1600. +# CS and CS1 each receive half of each row's pixel data +# (300 bytes = 600 pixels per controller, for all 1600 rows). +Seeed_reTerminal_E1004 = t133a01_base.extend( + "Seeed-reTerminal-E1004", + width=1200, + height=1600, + cs_pin=10, + cs1_pin=2, + dc_pin=11, + reset_pin=38, + busy_pin={ + "number": 13, + "inverted": True, + "mode": {"input": True}, + }, + enable_pin=12, +) diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index c7f34d7dd26..1396c18e3b1 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -154,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + # Select an ESP32 variant on which all of this model's pins are valid # (some models default to high-numbered pins only present on the S3). choose_variant_with_pins(_pins_for(model, config)) @@ -204,6 +208,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + # Select an ESP32 variant on which all of this model's pins are valid # (some models default to high-numbered pins only present on the S3). choose_variant_with_pins(_pins_for(model, config)) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index bb771f2132a..fb43b065677 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -76,6 +76,14 @@ display: - platform: epaper_spi model: seeed-reterminal-e1002 + - platform: epaper_spi + model: seeed-reterminal-e1004 + cs_pin: 33 + cs1_pin: 34 + dc_pin: 35 + reset_pin: 36 + busy_pin: 37 + enable_pin: 39 - platform: epaper_spi model: seeed-ee04-mono-4.26 full_update_every: 10 diff --git a/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml new file mode 100644 index 00000000000..27066710e0c --- /dev/null +++ b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml @@ -0,0 +1,38 @@ +esphome: + name: e1004-test + friendly_name: E1004 Test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +spi: + - id: epaper_spi_bus + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + spi_id: epaper_spi_bus + model: seeed-reterminal-e1004 + update_interval: never + lambda: |- + it.fill(Color::WHITE); + it.rectangle(10, 10, it.get_width() - 20, it.get_height() - 20, Color::BLACK); + it.print(it.get_width() / 2, it.get_height() / 2, id(my_font), Color::BLACK, TextAlign::CENTER, "E1004 Test"); + it.circle(100, 100, 30, Color(255, 0, 0)); + it.circle(200, 100, 30, Color(0, 255, 0)); + it.circle(300, 100, 30, Color(0, 0, 255)); + it.circle(400, 100, 30, Color(255, 255, 0)); + +font: + - file: "gfonts://Roboto" + id: my_font + size: 20 + +logger: From 4c8e45a222cbc505b0f102541dcbd091ef3918ae Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:17:49 -0500 Subject: [PATCH 270/343] Bump bundled esphome-device-builder to 1.0.28 (#17382) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9dec23db1bb..9367831a4c4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 RUN \ platformio settings set enable_telemetry No \ From fcfaa43e1eb9662179e12c375a57dda0959440d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:46:16 -0400 Subject: [PATCH 271/343] [ci] Name the sdk-nrf cache steps after the nRF Connect SDK (#17392) --- .github/actions/cache-sdk-nrf/action.yml | 6 +++--- .github/workflows/ci.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml index 71c09bfe14e..6cbb87cc66f 100644 --- a/.github/actions/cache-sdk-nrf/action.yml +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -1,4 +1,4 @@ -name: Cache sdk-nrf +name: Cache nRF Connect SDK description: > Resolve the pinned sdk-nrf version and cache the native sdk-nrf install (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. @@ -33,14 +33,14 @@ runs: # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it # lives in the default-branch scope readable by all PRs); PRs are # restore-only and never push multi-GB artifacts into their own scope. - - name: Cache sdk-nrf install (write on dev) + - name: Cache nRF Connect SDK install (write on dev) if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.esphome-sdk-nrf # yamllint disable-line rule:line-length key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} - - name: Cache sdk-nrf install (restore-only off dev) + - name: Cache nRF Connect SDK install (restore-only off dev) if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caf6453c1b3..34f8ed4878d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,7 +529,7 @@ jobs: with: framework: arduino - - name: Cache sdk-nrf install + - name: Cache nRF Connect SDK install if: matrix.cache_sdk_nrf uses: ./.github/actions/cache-sdk-nrf @@ -849,7 +849,7 @@ jobs: uses: ./.github/actions/cache-esp-idf with: restore-only: true - - name: Cache sdk-nrf install (restore-only) + - name: Cache nRF Connect SDK install (restore-only) # Only batches whose test platforms include nrf52 need the native # sdk-nrf install; never save -- just reuse the shared install the # dev nrf52 tidy job cached when present. From 2c24e82ba3ea058a1a5a3752c213f64cb62f6163 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:20:48 -0500 Subject: [PATCH 272/343] Bump bundled esphome-device-builder to 1.0.29 (#17384) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9367831a4c4..b325a424365 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.28 +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 RUN \ platformio settings set enable_telemetry No \ From 8ab19c0242a8af2eb3c3d8b04bfbffa33986a9c9 Mon Sep 17 00:00:00 2001 From: Chris Boot Date: Sun, 5 Jul 2026 02:48:30 +0100 Subject: [PATCH 273/343] [esp32] Add RTC-backed preferences (honor in_flash flag) (#17073) Co-authored-by: Claude Opus 4.8 --- esphome/components/esp32/preference_backend.h | 7 +- esphome/components/esp32/preferences.cpp | 103 ++++++++++++ esphome/components/esp32/preferences.h | 21 ++- esphome/components/esp8266/preferences.cpp | 28 +--- esphome/components/preferences/__init__.py | 10 ++ esphome/components/safe_mode/__init__.py | 5 +- esphome/components/safe_mode/safe_mode.cpp | 6 +- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/components/wifi/__init__.py | 31 +++- esphome/components/wifi/wifi_component.cpp | 8 +- esphome/const.py | 1 + esphome/core/defines.h | 2 + esphome/core/preferences_rtc.h | 54 +++++++ esphome/preferences.py | 106 +++++++++++++ script/ci-custom.py | 2 +- .../validate-rtc-storage.esp32-idf.yaml | 4 + .../safe_mode/test-rtc.esp32-idf.yaml | 4 + ...lidate-fast-connect-storage.esp32-idf.yaml | 7 + ...date-fast-connect-storage.esp8266-ard.yaml | 7 + tests/unit_tests/test_preferences.py | 149 ++++++++++++++++++ 20 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 esphome/core/preferences_rtc.h create mode 100644 esphome/preferences.py create mode 100644 tests/components/preferences/validate-rtc-storage.esp32-idf.yaml create mode 100644 tests/components/safe_mode/test-rtc.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml create mode 100644 tests/unit_tests/test_preferences.py diff --git a/esphome/components/esp32/preference_backend.h b/esphome/components/esp32/preference_backend.h index 893bc35f0c0..b0771b31283 100644 --- a/esphome/components/esp32/preference_backend.h +++ b/esphome/components/esp32/preference_backend.h @@ -11,8 +11,11 @@ class ESP32PreferenceBackend final { bool save(const uint8_t *data, size_t len); bool load(uint8_t *data, size_t len); - uint32_t key; - uint32_t nvs_handle; + uint32_t key{0}; + uint32_t nvs_handle{0}; // NVS (flash) path + uint16_t rtc_offset{0}; // RTC path: word offset into the RTC storage region + uint8_t length_words{0}; // RTC path: data length in 32-bit words + bool in_flash{true}; // true: store in NVS (flash); false: store in RTC memory }; class ESP32Preferences; diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 09835385ac2..dc2b40455cc 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -3,7 +3,10 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" +#include #include +#include #include #include @@ -18,6 +21,48 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// RTC memory backend for preferences requested with in_flash=false. Survives deep sleep and +// software/CPU resets, but not power loss; integrity is guarded by a per-record checksum so +// power-on garbage is detected on load. Keep this small: RTC memory is scarce and shared. +// +// Only compiled in when USE_ESP32_RTC_PREFERENCES_STORAGE is set (see preferences.h): the storage +// buffer reserves RTC memory, so it exists only when some config option actually selected RTC +// storage AND the variant has RTC memory (the ESP32-C2 and -C61 have none, so RTC_NOINIT_ATTR would +// have no section to land in and fail to link). Otherwise in_flash=false transparently falls back +// to NVS (see make_preference below). +// +// On variants with only RTC fast memory (C3/C6/H2/P4/C5/...) RTC_NOINIT_ATTR lands in RTC fast memory. +// This is still safe: the linker reserves .rtc_noinit ahead of any RTC-fast-as-heap pool +// (CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP), and IDF keeps the RTC fast power domain on in deep +// sleep (forced on whether or not it is used as heap), so the data is retained across both resets and +// deep sleep -- only power loss clears it. +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +static constexpr size_t RTC_PREF_SIZE_WORDS = 64; // 256 bytes +static constexpr size_t RTC_PREF_MAX_WORDS = 255; // length_words field is a uint8_t + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static RTC_NOINIT_ATTR uint32_t s_rtc_storage[RTC_PREF_SIZE_WORDS]; + +static bool save_to_rtc(uint16_t offset, uint32_t key, uint8_t length_words, const uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + rtc_pref_encode(&s_rtc_storage[offset], key, length_words, data, len); + return true; +} + +static bool load_from_rtc(uint16_t offset, uint32_t key, uint8_t length_words, uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + return rtc_pref_decode(&s_rtc_storage[offset], key, length_words, data, len); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + // open() runs from app_main() before the logger is initialized, so any failure // must be deferred until after global_logger is set. This is emitted from the // first make_preference() call, which runs from the generated setup() after @@ -25,6 +70,10 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return save_to_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -41,6 +90,10 @@ bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { } bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return load_from_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -94,6 +147,26 @@ void ESP32Preferences::open() { } } +ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!in_flash) + return this->make_rtc_preference_(length, type); +#else + if (!in_flash) { + // RTC storage is not compiled in (no config option selected it), so this request + // falls back to NVS -- the historic ESP32 behavior. Warn once so callers explicitly + // asking for RTC storage can discover the fallback. + static bool warned = false; + if (!warned) { + ESP_LOGW(TAG, "RTC preference storage not compiled in; using NVS (enable with 'preferences: rtc_storage: true')"); + warned = true; + } + } +#endif + // in_flash, or RTC storage not compiled in: fall back to NVS. + return this->make_preference(length, type); +} + ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { if (s_open_err != ESP_OK) { if (this->nvs_handle == 0) { @@ -106,10 +179,34 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; + pref->in_flash = true; return ESPPreferenceObject(pref); } +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +ESPPreferenceObject ESP32Preferences::make_rtc_preference_(size_t length, uint32_t type) { + const uint32_t length_words = rtc_pref_bytes_to_words(length); + if (length_words > RTC_PREF_MAX_WORDS) { + ESP_LOGE(TAG, "RTC preference too large: %" PRIu32 " words", length_words); + return {}; + } + const uint32_t total_words = length_words + 1; // +1 for checksum + if (static_cast(this->current_rtc_offset_) + total_words > RTC_PREF_SIZE_WORDS) { + ESP_LOGE(TAG, "RTC preference storage full, cannot allocate %" PRIu32 " words", total_words); + return {}; + } + auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + pref->key = type; + pref->in_flash = false; + pref->rtc_offset = this->current_rtc_offset_; + pref->length_words = static_cast(length_words); + this->current_rtc_offset_ += static_cast(total_words); + + return ESPPreferenceObject(pref); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + bool ESP32Preferences::sync() { if (s_pending_save.empty()) return true; @@ -186,6 +283,12 @@ bool ESP32Preferences::is_changed_(uint32_t nvs_handle, const NVSData &to_save, bool ESP32Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); s_pending_save.clear(); +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // Invalidate RTC-backed preferences too (checksum will no longer match). current_rtc_offset_ is + // deliberately left alone: existing backends keep pointing at their allocated slots, and reset() + // is always followed by a restart (same reason nvs_handle is zeroed below). + memset(s_rtc_storage, 0, sizeof(s_rtc_storage)); +#endif nvs_flash_deinit(); nvs_flash_erase(); diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 0e187d87a99..864d22312b9 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -2,6 +2,15 @@ #ifdef USE_ESP32 #include "esphome/core/preference_backend.h" +#include + +// RTC-backed preference storage is compiled in only when a config option actually selects it +// (USE_ESP32_RTC_PREFERENCES, emitted during code generation) and the variant has RTC memory +// (SOC_RTC_MEM_SUPPORTED; the ESP32-C2 and -C61 have none). Otherwise in_flash=false falls +// back to NVS and no RTC memory is reserved. +#if defined(USE_ESP32_RTC_PREFERENCES) && SOC_RTC_MEM_SUPPORTED +#define USE_ESP32_RTC_PREFERENCES_STORAGE +#endif namespace esphome::esp32 { @@ -11,9 +20,8 @@ class ESP32Preferences final : public PreferencesMixin { public: using PreferencesMixin::make_preference; void open(); - ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { - return this->make_preference(length, type); - } + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); + // Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior. ESPPreferenceObject make_preference(size_t length, uint32_t type); bool sync(); bool reset(); @@ -22,6 +30,13 @@ class ESP32Preferences final : public PreferencesMixin { protected: bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str); + +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // RTC-backed storage (in_flash=false). + ESPPreferenceObject make_rtc_preference_(size_t length, uint32_t type); + // Next free word offset in the RTC storage region (bump allocated in make_preference order). + uint16_t current_rtc_offset_{0}; +#endif }; void setup_preferences(); diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 696f83bce1a..d954ae4a0f3 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -8,6 +8,7 @@ extern "C" { #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" #include @@ -80,16 +81,6 @@ static uint32_t get_esp8266_flash_sector() { } static uint32_t get_esp8266_flash_address() { return get_esp8266_flash_sector() * SPI_FLASH_SEC_SIZE; } -static inline size_t bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } - -template uint32_t calculate_crc(It first, It last, uint32_t type) { - uint32_t crc = type; - while (first != last) { - crc ^= (*first++ * 2654435769UL) >> 1; - } - return crc; -} - static bool save_to_flash(size_t offset, const uint32_t *data, size_t len) { for (uint32_t i = 0; i < len; i++) { uint32_t j = offset + i; @@ -137,21 +128,19 @@ static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; bool ESP8266PreferenceBackend::save(const uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) return false; uint32_t buffer[PREF_MAX_BUFFER_WORDS]; - memset(buffer, 0, buffer_size * sizeof(uint32_t)); - memcpy(buffer, data, len); - buffer[this->length_words] = calculate_crc(buffer, buffer + this->length_words, this->type); + rtc_pref_encode(buffer, this->type, this->length_words, data, len); return this->in_flash ? save_to_flash(this->offset, buffer, buffer_size) : save_to_rtc(this->offset, buffer, buffer_size); } bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) @@ -161,10 +150,7 @@ bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { : load_from_rtc(this->offset, buffer, buffer_size); if (!ret) return false; - if (buffer[this->length_words] != calculate_crc(buffer, buffer + this->length_words, this->type)) - return false; - memcpy(data, buffer, len); - return true; + return rtc_pref_decode(buffer, this->type, this->length_words, data, len); } void ESP8266Preferences::setup() { @@ -177,13 +163,13 @@ void ESP8266Preferences::setup() { } ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { - const uint32_t length_words = bytes_to_words(length); + const uint32_t length_words = rtc_pref_bytes_to_words(length); if (length_words > MAX_PREFERENCE_WORDS) { ESP_LOGE(TAG, "Preference too large: %u words", static_cast(length_words)); return {}; } - const uint32_t total_words = length_words + 1; // +1 for CRC + const uint32_t total_words = length_words + 1; // +1 for checksum uint16_t offset; if (in_flash) { diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index c4268727282..f3f2f632c94 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -1,3 +1,4 @@ +from esphome import preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID @@ -10,10 +11,17 @@ preferences_ns = cg.esphome_ns.namespace("preferences") IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.Component) CONF_FLASH_WRITE_INTERVAL = "flash_write_interval" +CONF_RTC_STORAGE = "rtc_storage" CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(IntervalSyncer), cv.Optional(CONF_FLASH_WRITE_INTERVAL, default="60s"): cv.update_interval, + # Compile the RTC-backed storage into the ESP32 preferences backend even + # when no other option selects it, so components (including external + # ones) requesting in_flash=false are honoured instead of falling back + # to NVS. No default: absence means "no request" (see + # preferences.validate_rtc_storage for the per-platform rules). + cv.Optional(CONF_RTC_STORAGE): preferences.validate_rtc_storage, } ).extend(cv.COMPONENT_SCHEMA) @@ -26,4 +34,6 @@ async def to_code(config): cg.add_define("USE_PREFERENCES_SYNC_EVERY_LOOP") else: cg.add(var.set_write_interval(write_interval)) + if config.get(CONF_RTC_STORAGE): + preferences.request_rtc_storage() await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 578376258a1..c11447e604e 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -7,6 +7,7 @@ from esphome.const import ( CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, + CONF_STORAGE, KEY_PAST_SAFE_MODE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All( CONF_REBOOT_TIMEOUT, default="5min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_ON_SAFE_MODE): automation.validate_automation({}), + **preferences.storage_schema(), } ).extend(cv.COMPONENT_SCHEMA), _remove_id_if_disabled, @@ -87,6 +89,7 @@ async def to_code(config): config[CONF_NUM_ATTEMPTS], config[CONF_REBOOT_TIMEOUT], config[CONF_BOOT_IS_GOOD_AFTER], + preferences.is_in_flash(config[CONF_STORAGE]), ) cg.add(RawExpression(f"if ({condition}) return")) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 5c0047dca0a..2eb1085ee50 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -162,13 +162,13 @@ bool SafeModeComponent::get_safe_mode_pending() { return this->read_rtc_() == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; } -bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, - uint32_t boot_is_good_after) { +bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, + bool in_flash) { this->safe_mode_start_time_ = millis(); this->safe_mode_enable_time_ = enable_time; this->safe_mode_boot_is_good_after_ = boot_is_good_after; this->safe_mode_num_attempts_ = num_attempts; - this->rtc_ = global_preferences->make_preference(RTC_KEY, false); + this->rtc_ = global_preferences->make_preference(RTC_KEY, in_flash); #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) // Check partition state to detect if bootloader supports rollback diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 94db4357eb9..d81b8a42d10 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,7 +17,7 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: - bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after); + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode void set_safe_mode_pending(const bool &pending); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 512fd63e125..111f4cfc849 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,10 +1,10 @@ import logging import math -from esphome import automation +from esphome import automation, preferences from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( add_idf_sdkconfig_option, const, @@ -50,6 +50,7 @@ from esphome.const import ( CONF_REBOOT_TIMEOUT, CONF_SSID, CONF_STATIC_IP, + CONF_STORAGE, CONF_SUBNET, CONF_TIMEOUT, CONF_TTLS_PHASE_2, @@ -434,6 +435,22 @@ def _validate(config): CONF_PASSIVE_SCAN = "passive_scan" + +FAST_CONNECT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + **preferences.storage_schema(), + } +) + + +def _fast_connect_schema(value): + """Accept the historic plain boolean or a dict with enabled/storage keys.""" + if isinstance(value, bool): + value = {CONF_ENABLED: value} + return FAST_CONNECT_SCHEMA(value) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -459,7 +476,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx="none", ln882x="light", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), - cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, + cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, @@ -619,8 +636,14 @@ async def to_code(config): cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) if CONF_MIN_AUTH_MODE in config: cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) - if config[CONF_FAST_CONNECT]: + fast_connect = config[CONF_FAST_CONNECT] + if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") + # The storage default preserves this preference's historic location: + # ESP8266 has always used RTC memory; every other platform effectively + # used flash (the in_flash flag was previously ignored outside ESP8266). + if preferences.is_in_flash(fast_connect[CONF_STORAGE]): + cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ffc6ea8e144..2f6bec6bb26 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -649,7 +649,13 @@ void WiFiComponent::start() { this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT - this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); +#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH + const bool fast_connect_in_flash = true; +#else + const bool fast_connect_in_flash = false; +#endif + this->fast_connect_pref_ = + global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif SavedWifiSettings save{}; diff --git a/esphome/const.py b/esphome/const.py index 331eb5011d8..24bb4ea31f5 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -976,6 +976,7 @@ CONF_STEP_PIN = "step_pin" CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" +CONF_STORAGE = "storage" CONF_STORE_BASELINE = "store_baseline" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ff4bccc6931..987e2d7a2a8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,6 +240,7 @@ #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION #define USE_ESP32_MIN_CHIP_REVISION_SET +#define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM #define USE_BLUETOOTH_PROXY @@ -300,6 +301,7 @@ #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/esphome/core/preferences_rtc.h b/esphome/core/preferences_rtc.h new file mode 100644 index 00000000000..30b004f9940 --- /dev/null +++ b/esphome/core/preferences_rtc.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +// Shared storage format for word-addressable preference backends. +// +// Several platforms persist preferences as a buffer of 32-bit words followed by a +// single checksum word, seeded with the preference's `type` (its hashed key). This +// format is used for RTC user memory (ESP8266, ESP32) and for the ESP8266 +// flash-emulation buffer. The helpers here are platform independent; each backend +// supplies its own word read/write primitives and offset allocation. + +/// Round a byte count up to whole 32-bit words. +inline size_t rtc_pref_bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } + +/// Compute the integrity checksum over [first, last), seeded with `type`. +/// Iterates over 32-bit words; the result is stored as the trailing word of a record. +/// (Not a true CRC -- it XORs each word after a Fibonacci-hash multiply -- but the +/// algorithm is kept as-is for compatibility with records written by old firmware.) +template uint32_t rtc_pref_calculate_checksum(It first, It last, uint32_t type) { + uint32_t checksum = type; + while (first != last) { + // UINT32_C keeps the multiply wrapping at 32 bits regardless of the width of + // unsigned long, so 64-bit host builds compute the same value as the devices. + checksum ^= (*first++ * UINT32_C(2654435769)) >> 1; + } + return checksum; +} + +/// Encode `len` data bytes into `buffer` (length_words data words + 1 trailing checksum word). +/// `buffer` must have capacity for at least `length_words + 1` words. Trailing padding in +/// the final data word is zeroed so the checksum is deterministic. +inline void rtc_pref_encode(uint32_t *buffer, uint32_t type, uint8_t length_words, const uint8_t *data, size_t len) { + memset(buffer, 0, (static_cast(length_words) + 1) * sizeof(uint32_t)); + memcpy(buffer, data, len); + buffer[length_words] = rtc_pref_calculate_checksum(buffer, buffer + length_words, type); +} + +/// Verify the checksum of a record held in `buffer` (length_words data words + 1 checksum +/// word) and, on success, copy `len` bytes out to `data`. Returns false on checksum mismatch +/// (e.g. the record was never written or RTC memory holds power-on garbage). +inline bool rtc_pref_decode(const uint32_t *buffer, uint32_t type, uint8_t length_words, uint8_t *data, size_t len) { + if (buffer[length_words] != rtc_pref_calculate_checksum(buffer, buffer + length_words, type)) { + return false; + } + memcpy(data, buffer, len); + return true; +} + +} // namespace esphome diff --git a/esphome/preferences.py b/esphome/preferences.py new file mode 100644 index 00000000000..fce85191302 --- /dev/null +++ b/esphome/preferences.py @@ -0,0 +1,106 @@ +"""Helpers for letting a component choose where a preference is persisted. + +Preferences can be stored either in flash (durable across power loss) or in RTC +memory (fast, survives deep sleep and soft resets but not power loss). The +flash-vs-RTC choice is only meaningful on platforms whose preferences backend +honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms +the value is accepted only as ``flash`` (the sole supported backend). + +Components include :func:`storage_schema` in their config and convert the chosen +value with :func:`is_in_flash` when calling ``make_preference``. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_STORAGE +from esphome.core import CORE + +STORAGE_FLASH = "flash" +STORAGE_RTC = "rtc" + + +def _rtc_supported() -> bool: + """Whether the active platform has an RTC-backed preferences backend. + + Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 + and -C61 have no RTC memory at all, so RTC storage is unavailable there. + """ + if CORE.is_esp8266: + return True + if CORE.is_esp32: + from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + + return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) + return False + + +def _default_storage() -> str: + """Default that preserves each platform's historic behavior. + + ESP8266 has always stored these preferences in RTC memory; every other + platform effectively used flash. Evaluated at validation time. + """ + return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH + + +def _validate_storage(value): + value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) + if value == STORAGE_RTC and not _rtc_supported(): + raise cv.Invalid( + f"'{STORAGE_RTC}' storage is not supported on this platform; only " + f"'{STORAGE_FLASH}' is available" + ) + return value + + +def storage_schema(): + """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" + return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + + +def request_rtc_storage() -> None: + """Compile the RTC-backed storage into the ESP32 preferences backend. + + The RTC storage region is left out of ESP32 builds unless something asks for + it, so unused builds don't reserve RTC memory. Call this from ``to_code`` + when a config option selects RTC storage. No-op on other platforms (ESP8266 + always has its RTC backend). + """ + if CORE.is_esp32: + cg.add_define("USE_ESP32_RTC_PREFERENCES") + + +def validate_rtc_storage(value): + """Validate a boolean option that requests RTC-backed preference storage. + + ``false`` means "no request", not "disable": it never turns RTC storage off + (another option selecting ``storage: rtc`` still compiles it in). On ESP8266 + the backend is integral and always enabled, so ``false`` is rejected rather + than silently ignored; ``true`` is a tolerated no-op there so shared config + packages work across mixed fleets. + """ + value = cv.boolean(value) + if not value: + if CORE.is_esp8266: + raise cv.Invalid( + "RTC preference storage is always enabled on ESP8266 and cannot " + "be disabled" + ) + return value + if not _rtc_supported(): + raise cv.Invalid("RTC preference storage is not supported on this platform") + return value + + +def is_in_flash(value: str) -> bool: + """Map a CONF_STORAGE value to the ``in_flash`` argument of make_preference. + + Call this from ``to_code``: when RTC storage is selected on ESP32 it also emits + the define that compiles the RTC storage buffer into the ESP32 backend (see + :func:`request_rtc_storage`). + """ + in_flash = value == STORAGE_FLASH + if not in_flash: + request_rtc_storage() + return in_flash diff --git a/script/ci-custom.py b/script/ci-custom.py index 4568732b882..75f4d71ba43 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -555,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1014 +CONST_PY_MAX_CONF = 1015 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml new file mode 100644 index 00000000000..1808e09f5d7 --- /dev/null +++ b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the opt-in that compiles the RTC-backed preference storage into +# the ESP32 backend without any other option selecting it. +preferences: + rtc_storage: true diff --git a/tests/components/safe_mode/test-rtc.esp32-idf.yaml b/tests/components/safe_mode/test-rtc.esp32-idf.yaml new file mode 100644 index 00000000000..113a2b6ab52 --- /dev/null +++ b/tests/components/safe_mode/test-rtc.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the ESP32 RTC-backed preferences path (storage: rtc) for safe_mode. +safe_mode: + num_attempts: 3 + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml new file mode 100644 index 00000000000..93d223b908d --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect with RTC-backed preference storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + enabled: true + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml new file mode 100644 index 00000000000..070e22fd5b1 --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect overriding the ESP8266 default (rtc) +# back to flash storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + storage: flash diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py new file mode 100644 index 00000000000..677eeee7f26 --- /dev/null +++ b/tests/unit_tests/test_preferences.py @@ -0,0 +1,149 @@ +"""Tests for esphome.preferences storage backend selection.""" + +import pytest + +from esphome import preferences +from esphome.components.esp32 import KEY_ESP32 +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C61, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_STORAGE, + KEY_CORE, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE + + +def _set_platform(platform: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + +def _set_esp32(variant: str) -> None: + _set_platform(PLATFORM_ESP32) + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + + +def _validate(value: dict): + return cv.Schema(preferences.storage_schema())(value) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_is_in_flash() -> None: + _set_platform(PLATFORM_ESP8266) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + # The RTC storage define is ESP32-specific. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_is_in_flash_esp32_rtc_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_esp32_only() -> None: + _set_platform(PLATFORM_ESP8266) + preferences.request_rtc_storage() + # ESP8266 always has its RTC backend; no define is needed or emitted. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_request_rtc_storage_esp32_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + preferences.request_rtc_storage() + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_validate_rtc_storage_accepted(variant: str) -> None: + _set_esp32(variant) + assert preferences.validate_rtc_storage(True) is True + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + # Tolerated no-op: the ESP8266 backend always has RTC storage. + assert preferences.validate_rtc_storage(True) is True + # But it cannot be disabled, so an explicit false is an error. + with pytest.raises(cv.Invalid, match="always enabled on ESP8266"): + preferences.validate_rtc_storage(False) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_validate_rtc_storage_rejected_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + # Disabling it is always fine. + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + # Defaults preserve each platform's historic behavior. + (PLATFORM_ESP8266, preferences.STORAGE_RTC), + (PLATFORM_RP2040, preferences.STORAGE_FLASH), + ], +) +def test_default_storage_per_platform(platform: str, expected: str) -> None: + _set_platform(platform) + assert _validate({})[CONF_STORAGE] == expected + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C2]) +def test_default_storage_esp32_is_flash(variant: str) -> None: + # ESP32 defaults to flash on every variant, including those without RTC memory. + _set_esp32(variant) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_rtc_allowed_on_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_allowed_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_rejected_on_esp32_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_rtc_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_flash_allowed_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH From c720186170c45425910e1cf1d604aaef25f244bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:50:13 +0200 Subject: [PATCH 274/343] Bump smpclient from 6.0.0 to 7.2.0 (#16928) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 95388f278f3..8b028554a80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 -smpclient==6.0.0 +smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.10.0 # native esp-idf toolchain global cache dir From f6c260a2c5050902aa48ffac85102d927e89aa4b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:01:48 +1000 Subject: [PATCH 275/343] [ci] Make import time budget more realistic (#17406) --- script/import_time_budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index 855d89c56da..e8108175074 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", "margin_pct": 20, - "cumulative_us": 91000 + "cumulative_us": 95000 } From 105d1362a20d52ffe251a93de368bd93b625f1e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:31:07 -0500 Subject: [PATCH 276/343] Bump bundled esphome-device-builder to 1.1.0 (#17412) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b325a424365..a54bf3e79e8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.0.29 +RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 RUN \ platformio settings set enable_telemetry No \ From b9588a898497d407be1c263dffae07542d5ed01b Mon Sep 17 00:00:00 2001 From: crimike Date: Mon, 6 Jul 2026 01:29:03 +0200 Subject: [PATCH 277/343] [mipi_spi] Add Waveshare-ESP32-S3-TOUCH-AMOLED-1.64 (#17386) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_spi/models/amoled.py | 4 ++++ esphome/components/mipi_spi/models/waveshare.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 32cad70ac0b..30e815d68ef 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,6 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD +from esphome.config_validation import UNDEFINED DriverChip( "T-DISPLAY-S3-AMOLED", @@ -97,6 +98,9 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, + swap_xy=UNDEFINED, + width=480, + height=480, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 3c719b0f5e2..8fc5b2acc59 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -282,3 +282,13 @@ ST7789V.extend( invert_colors=True, data_rate="40MHz", ) + +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.64", + width=280, + height=456, + offset_width=20, + cs_pin=9, + reset_pin=21, + enable_pin=1, +) From 3f94e6dcbbec9d3b6c9e8f83308c605b19216b4c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 01:52:39 +0200 Subject: [PATCH 278/343] [nrf52] let user select libc version (#17408) --- esphome/components/nrf52/__init__.py | 13 +++++++++++++ esphome/components/zephyr/__init__.py | 2 -- tests/components/nrf52/test.nrf52-xiao-ble.yaml | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index a5f2018d551..661fc0758e4 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -200,6 +200,7 @@ DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" CONF_DCDC = "dcdc" +CONF_LIBC_NANO = "libc_nano" CONF_REG0 = "reg0" CONF_UICR_ERASE = "uicr_erase" @@ -248,6 +249,7 @@ CONFIG_SCHEMA = cv.All( ): cv.Schema( { cv.Optional(CONF_VERSION): cv.string_strict, + cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional( @@ -273,6 +275,7 @@ def _validate_mcumgr(config): def _final_validate(config): + if CONF_DFU in config: _validate_mcumgr(config) if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: @@ -283,6 +286,13 @@ def _final_validate(config): conf = config[CONF_FRAMEWORK] advanced = conf[CONF_ADVANCED] + if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations: + _LOGGER.warning( + "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers " + "such as %%zu are not supported and will print incorrectly. " + "Set 'libc_nano: false' under 'framework:' to use the full newlib." + ) + if advanced[CONF_ENABLE_OTA_ROLLBACK]: # "disabled: false" means safe mode *is* enabled. safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True}) @@ -379,6 +389,9 @@ async def to_code(config: ConfigType) -> None: # Enable OTA rollback support if advanced[CONF_ENABLE_OTA_ROLLBACK]: cg.add_define("USE_OTA_ROLLBACK") + zephyr_add_prj_conf("NEWLIB_LIBC", True) + zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) + zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO]) # c++ support if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("CPLUSPLUS", True) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d6c45a744c9..b98f94d37ae 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -134,9 +134,7 @@ def zephyr_to_code(config: ConfigType) -> None: cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") # c++ support - zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("FPU", True) - zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) zephyr_add_prj_conf("STD_CPP20", True) # random_bytes() uses sys_rand_get() which requires the entropy subsystem zephyr_add_prj_conf("ENTROPY_GENERATOR", True) diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index de4c0c6e00f..e1b5f088bb8 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -2,3 +2,5 @@ nrf52: dfu: true reg0: voltage: 1.8V + framework: + libc_nano: false From 39c0f9cc848a68c18b0e4d61149ec82fcf15df36 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:57:56 +1200 Subject: [PATCH 279/343] [cst328] Use dict-style packages so batch grouping deduplicates the i2c bus (#17413) --- tests/components/cst328/test.esp32-idf.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml index 3dc184e3286..9c4594510f8 100644 --- a/tests/components/cst328/test.esp32-idf.yaml +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -4,5 +4,6 @@ substitutions: reset_pin: "21" packages: - - !include ../../test_build_components/common/i2c/esp32-idf.yaml - - !include common.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml From e095c457ff831c36d3c2ece3f1d4148a348e6f07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jul 2026 22:02:12 -0500 Subject: [PATCH 280/343] [esp32] Suppress -Wvolatile in the direct ESP-IDF build (#17404) --- esphome/build_gen/espidf.py | 16 +++++++++- esphome/build_gen/platformio.py | 5 ++-- esphome/codegen.py | 1 + esphome/core/__init__.py | 9 ++++++ esphome/core/config.py | 9 ++++++ esphome/cpp_generator.py | 9 ++++++ esphome/framework_helpers.py | 7 +++++ tests/unit_tests/build_gen/test_espidf.py | 23 +++++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 29 +++++++++++++++++++ tests/unit_tests/test_framework_helpers.py | 23 +++++++++++++++ 10 files changed, 127 insertions(+), 4 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index dec6ea04deb..cc2fc5c4cd5 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,7 +6,11 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE -from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_cxx_compile_flags, + get_project_link_flags, +) from esphome.helpers import mkdir_p, write_file_if_changed # Replaces the IDF default C++ standard (-std=gnu++2b appended to @@ -91,6 +95,14 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + # Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS + # (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as + # -Wno-volatile is passed on a C compile. + cxx_compile_options = "\n".join( + f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)' + for flag in get_project_cxx_compile_flags() + ) + cpp_standard_options = ( CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) if CORE.cpp_standard @@ -155,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} +{cxx_compile_options} + {extra_compile_options} {managed_components_property} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index a583279ea7a..b63c4b733d4 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -108,7 +108,6 @@ Import("env") def write_cxx_flags_script() -> None: path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME) contents = CXX_FLAGS_FILE_CONTENTS - if not CORE.is_host: - contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])' - contents += "\n" + for flag in sorted(CORE.cxx_build_flags): + contents += f'env.Append(CXXFLAGS=["{flag}"])\n' write_file_if_changed(path, contents) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe4479..56a47d146ed 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cxx_build_flag, add_define, add_global, add_library, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 21ff7ef07c7..89ce27a8b99 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -591,6 +591,9 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A set of build flags that apply to C++ compiles only (CXXFLAGS / + # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C + self.cxx_build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() @@ -650,6 +653,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None self.defines = set() @@ -957,6 +961,11 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cxx_build_flag(self, build_flag: str) -> str: + self.cxx_build_flags.add(build_flag) + _LOGGER.debug("Adding C++ build flag: %s", build_flag) + return build_flag + def add_build_unflag(self, build_unflag: str) -> None: if self.using_toolchain_esp_idf: # The native ESP-IDF build generator does not consume build_unflags diff --git a/esphome/core/config.py b/esphome/core/config.py index 0670fde0ff2..ebad5cf1656 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -723,6 +723,15 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + # C++20 deprecated ++/--, compound assignment, and chained assignment on + # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. + # C++23 (P2327R1) removed the deprecation for compound assignment, so the + # warning flags patterns that are valid again under newer standards. + # C++-only flag: GCC warns when it is passed on a C compile, hence + # add_cxx_build_flag. Skipped for host builds, where the compiler may be + # clang, which does not know this GCC option. + if not CORE.is_host: + cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 582b8fc74da..6bcf4eed77c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,15 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cxx_build_flag(build_flag: str) -> None: + """Add a global build flag that applies to C++ compiles only. + + Use for flags GCC rejects or warns about when passed on C compiles + (e.g. ``-Wno-volatile``). + """ + CORE.add_cxx_build_flag(build_flag) + + def add_build_unflag(build_unflag: str) -> None: """Add a global build unflag to the compiler flags.""" CORE.add_build_unflag(build_unflag) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 69cecc58e20..70d440d995c 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -37,6 +37,13 @@ def get_project_compile_flags() -> list[str]: ] +def get_project_cxx_compile_flags() -> list[str]: + """Return the sorted flags that apply to C++ compiles only.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(CORE.cxx_build_flags) + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 0f4444f719b..bcd9fa655ae 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -243,6 +243,7 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), patch.object(CORE, "name", "test"), patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", set()), ): from esphome.build_gen.espidf import get_project_cmakelists @@ -251,6 +252,28 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: assert "CXX_COMPILE_OPTIONS" not in content +def test_get_project_cmakelists_cxx_build_flags(tmp_path: Path) -> None: + """Flags registered via cg.add_cxx_build_flag() are appended to + CXX_COMPILE_OPTIONS (C++-only, GCC warns if they reach C compiles) + between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", {"-Wno-volatile"}), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + flag_line = 'idf_build_set_property(CXX_COMPILE_OPTIONS "-Wno-volatile" APPEND)' + assert flag_line in content + include_pos = content.index("tools/cmake/project.cmake") + flag_pos = content.index(flag_line) + project_pos = content.index("project(test)") + assert include_pos < flag_pos < project_pos + + def test_get_component_cmakelists_no_compile_features() -> None: """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the top-level CMakeLists; the src component must not set its own.""" diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 2ae3836a25e..3df2fb1036a 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -200,3 +200,32 @@ def test_get_ini_content_no_cpp_standard( content = platformio.get_ini_content() assert "-std=" not in content + + +def test_write_cxx_flags_script_emits_registered_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Flags registered via cg.add_cxx_build_flag() are emitted as CXXFLAGS, + sorted, so they apply to C++ compiles only.""" + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", {"-Wno-volatile", "-Wno-deprecated"}) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert ( + 'env.Append(CXXFLAGS=["-Wno-deprecated"])\n' + 'env.Append(CXXFLAGS=["-Wno-volatile"])\n' + ) in content + + +def test_write_cxx_flags_script_no_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", set()) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert "CXXFLAGS" not in content diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 6fe62dcc8cb..69b9f20eaa6 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -26,6 +26,7 @@ from esphome.framework_helpers import ( create_venv, download_from_mirrors, get_project_compile_flags, + get_project_cxx_compile_flags, get_project_link_flags, get_python_env_executable_path, get_system_python_path, @@ -1048,3 +1049,25 @@ class TestGetProjectLinkFlags: ): result = get_project_link_flags() assert result == sorted(result) + + +def _make_core_cxx(flags: set[str]) -> MagicMock: + core = MagicMock() + core.cxx_build_flags = flags + return core + + +class TestGetProjectCxxCompileFlags: + def test_returns_sorted_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core_cxx({"-Wno-volatile", "-Wno-deprecated"}), + ): + assert get_project_cxx_compile_flags() == [ + "-Wno-deprecated", + "-Wno-volatile", + ] + + def test_empty_flags(self) -> None: + with patch("esphome.core.CORE", _make_core_cxx(set())): + assert get_project_cxx_compile_flags() == [] From fd86417bf56a587aefae62adb82ab527db35cf64 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:29:45 +1200 Subject: [PATCH 281/343] [cst328] Update test package (#17415) --- tests/components/cst328/test.esp32-idf.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml index 9c4594510f8..ac4ad140a81 100644 --- a/tests/components/cst328/test.esp32-idf.yaml +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -5,5 +5,4 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + cst328: !include common.yaml From af7b6e35895bca7d8b92951a374e7f4da2ffa243 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:26 +1200 Subject: [PATCH 282/343] Mark configurable classes as final (17/21: ssd1351_spi-tem3200) (#16968) --- esphome/components/ssd1351_spi/ssd1351_spi.h | 6 +++--- esphome/components/st7567_i2c/st7567_i2c.h | 2 +- esphome/components/st7567_spi/st7567_spi.h | 6 +++--- esphome/components/st7701s/st7701s.h | 6 +++--- esphome/components/st7735/st7735.h | 6 +++--- esphome/components/st7789v/st7789v.h | 6 +++--- esphome/components/st7920/st7920.h | 6 +++--- esphome/components/statsd/statsd.h | 2 +- .../components/status/status_binary_sensor.h | 2 +- .../status_led/light/status_led_light.h | 2 +- esphome/components/status_led/status_led.h | 2 +- esphome/components/stepper/stepper.h | 10 +++++----- esphome/components/sts3x/sts3x.h | 4 +++- esphome/components/stts22h/stts22h.h | 2 +- esphome/components/sun/sensor/sun_sensor.h | 2 +- esphome/components/sun/sun.h | 6 +++--- .../sun/text_sensor/sun_text_sensor.h | 2 +- esphome/components/sun_gtil2/sun_gtil2.h | 2 +- esphome/components/switch/automation.h | 18 +++++++++--------- .../binary_sensor/switch_binary_sensor.h | 2 +- esphome/components/sx126x/automation.h | 12 ++++++------ .../sx126x/packet_transport/sx126x_transport.h | 2 +- esphome/components/sx126x/sx126x.h | 6 +++--- esphome/components/sx127x/automation.h | 12 ++++++------ .../sx127x/packet_transport/sx127x_transport.h | 2 +- esphome/components/sx127x/sx127x.h | 6 +++--- .../sx1509_binary_keypad_sensor.h | 2 +- .../sx1509/output/sx1509_float_output.h | 2 +- esphome/components/sx1509/sx1509.h | 10 +++++----- esphome/components/sx1509/sx1509_gpio_pin.h | 2 +- .../binary_sensor/sy6970_binary_sensor.h | 4 ++-- .../components/sy6970/sensor/sy6970_sensor.h | 2 +- esphome/components/sy6970/sy6970.h | 2 +- .../sy6970/text_sensor/sy6970_text_sensor.h | 6 +++--- esphome/components/syslog/esphome_syslog.h | 2 +- esphome/components/t6615/t6615.h | 2 +- esphome/components/tc74/tc74.h | 2 +- esphome/components/tca9548a/tca9548a.h | 4 ++-- esphome/components/tca9555/tca9555.h | 8 ++++---- esphome/components/tcl112/tcl112.h | 2 +- esphome/components/tcs34725/tcs34725.h | 2 +- esphome/components/tee501/tee501.h | 2 +- .../teleinfo/sensor/teleinfo_sensor.h | 2 +- esphome/components/teleinfo/teleinfo.h | 2 +- .../text_sensor/teleinfo_text_sensor.h | 2 +- esphome/components/tem3200/tem3200.h | 2 +- 46 files changed, 99 insertions(+), 97 deletions(-) diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.h b/esphome/components/ssd1351_spi/ssd1351_spi.h index 5ce41c1f9ed..307807d19f1 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.h +++ b/esphome/components/ssd1351_spi/ssd1351_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1351_spi { -class SPISSD1351 : public ssd1351_base::SSD1351, - public spi::SPIDevice { +class SPISSD1351 final : public ssd1351_base::SSD1351, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index 49489d79e67..eea3068e030 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -6,7 +6,7 @@ namespace esphome::st7567_i2c { -class I2CST7567 : public st7567_base::ST7567, public i2c::I2CDevice { +class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/st7567_spi/st7567_spi.h b/esphome/components/st7567_spi/st7567_spi.h index fb6f9501a92..e4699437adc 100644 --- a/esphome/components/st7567_spi/st7567_spi.h +++ b/esphome/components/st7567_spi/st7567_spi.h @@ -6,9 +6,9 @@ namespace esphome::st7567_spi { -class SPIST7567 : public st7567_base::ST7567, - public spi::SPIDevice { +class SPIST7567 final : public st7567_base::ST7567, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index c65a213929f..d44f8c68590 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -26,9 +26,9 @@ const uint8_t CMD2_BKSEL = 0xFF; const uint8_t CMD2_BK0[5] = {0x77, 0x01, 0x00, 0x00, 0x10}; const uint8_t ST7701S_DELAY_FLAG = 0xFF; -class ST7701S : public display::Display, - public spi::SPIDevice { +class ST7701S final : public display::Display, + public spi::SPIDevice { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/st7735/st7735.h b/esphome/components/st7735/st7735.h index 7fa0ad73357..28bc0916f9b 100644 --- a/esphome/components/st7735/st7735.h +++ b/esphome/components/st7735/st7735.h @@ -31,9 +31,9 @@ enum ST7735Model { ST7735_INITR_18REDTAB = INITR_18REDTAB }; -class ST7735 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7735 final : public display::DisplayBuffer, + public spi::SPIDevice { public: ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors); diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 3f9942b1173..1b7ba318a6c 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -106,9 +106,9 @@ static const uint8_t ST7789_MADCTL_GS = 0x01; static const uint8_t ST7789_MADCTL_COLOR_ORDER = ST7789_MADCTL_BGR; -class ST7789V : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7789V final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model_str(const char *model_str); void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } diff --git a/esphome/components/st7920/st7920.h b/esphome/components/st7920/st7920.h index 71fe7aa89c0..0160c5270fd 100644 --- a/esphome/components/st7920/st7920.h +++ b/esphome/components/st7920/st7920.h @@ -10,9 +10,9 @@ class ST7920; using st7920_writer_t = display::DisplayWriter; -class ST7920 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7920 final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(st7920_writer_t &&writer) { this->writer_local_ = writer; } void set_height(uint16_t height) { this->height_ = height; } diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 77f3d797c5c..7cbde6d7438 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -27,7 +27,7 @@ namespace esphome::statsd { -class StatsdComponent : public PollingComponent { +class StatsdComponent final : public PollingComponent { public: ~StatsdComponent(); diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 7e8c31d7415..28cf4cd0832 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::status { -class StatusBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void update() override; diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index 0483669d0ab..5eb0d3c085c 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -7,7 +7,7 @@ namespace esphome::status_led { -class StatusLEDLightOutput : public light::LightOutput, public Component { +class StatusLEDLightOutput final : public light::LightOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_output(output::BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index bda144d2cdd..3688dba8d65 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -5,7 +5,7 @@ namespace esphome::status_led { -class StatusLED : public Component { +class StatusLED final : public Component { public: explicit StatusLED(GPIOPin *pin); diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 9fbd0d92e6e..06ef3bab375 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -37,7 +37,7 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction : public Action { +template class SetTargetAction final : public Action { public: explicit SetTargetAction(Stepper *parent) : parent_(parent) {} @@ -49,7 +49,7 @@ template class SetTargetAction : public Action { Stepper *parent_; }; -template class ReportPositionAction : public Action { +template class ReportPositionAction final : public Action { public: explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} @@ -61,7 +61,7 @@ template class ReportPositionAction : public Action { Stepper *parent_; }; -template class SetSpeedAction : public Action { +template class SetSpeedAction final : public Action { public: explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} @@ -77,7 +77,7 @@ template class SetSpeedAction : public Action { Stepper *parent_; }; -template class SetAccelerationAction : public Action { +template class SetAccelerationAction final : public Action { public: explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} @@ -92,7 +92,7 @@ template class SetAccelerationAction : public Action { Stepper *parent_; }; -template class SetDecelerationAction : public Action { +template class SetDecelerationAction final : public Action { public: explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} diff --git a/esphome/components/sts3x/sts3x.h b/esphome/components/sts3x/sts3x.h index 038fa0dd802..6752cf689b7 100644 --- a/esphome/components/sts3x/sts3x.h +++ b/esphome/components/sts3x/sts3x.h @@ -9,7 +9,9 @@ namespace esphome::sts3x { /// This class implements support for the ST3x-DIS family of temperature i2c sensors. -class STS3XComponent : public sensor::Sensor, public PollingComponent, public sensirion_common::SensirionI2CDevice { +class STS3XComponent final : public sensor::Sensor, + public PollingComponent, + public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index 442a263e49c..d8d7a485cf6 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -6,7 +6,7 @@ namespace esphome::stts22h { -class STTS22HComponent : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class STTS22HComponent final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/sun/sensor/sun_sensor.h b/esphome/components/sun/sensor/sun_sensor.h index 148e5297d94..bec1a1af672 100644 --- a/esphome/components/sun/sensor/sun_sensor.h +++ b/esphome/components/sun/sensor/sun_sensor.h @@ -11,7 +11,7 @@ enum SensorType { SUN_SENSOR_AZIMUTH, }; -class SunSensor : public sensor::Sensor, public PollingComponent { +class SunSensor final : public sensor::Sensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_type(SensorType type) { type_ = type; } diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index 2999c93c715..ea9e05042d9 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -51,7 +51,7 @@ struct HorizontalCoordinate { } // namespace internal -class Sun { +class Sun final { public: void set_time(time::RealTimeClock *time) { time_ = time; } time::RealTimeClock *get_time() const { return time_; } @@ -78,7 +78,7 @@ class Sun { internal::GeoLocation location_; }; -class SunTrigger : public Trigger<>, public PollingComponent, public Parented { +class SunTrigger final : public Trigger<>, public PollingComponent, public Parented { public: SunTrigger() : PollingComponent(60000) {} @@ -109,7 +109,7 @@ class SunTrigger : public Trigger<>, public PollingComponent, public Parented class SunCondition : public Condition, public Parented { +template class SunCondition final : public Condition, public Parented { public: TEMPLATABLE_VALUE(double, elevation); void set_above(bool above) { above_ = above; } diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.h b/esphome/components/sun/text_sensor/sun_text_sensor.h index 65b0e358d0b..a247a95e067 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.h +++ b/esphome/components/sun/text_sensor/sun_text_sensor.h @@ -8,7 +8,7 @@ namespace esphome::sun { -class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { +class SunTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_elevation(double elevation) { elevation_ = elevation; } diff --git a/esphome/components/sun_gtil2/sun_gtil2.h b/esphome/components/sun_gtil2/sun_gtil2.h index e774fefcf8c..dc3516f2b51 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.h +++ b/esphome/components/sun_gtil2/sun_gtil2.h @@ -15,7 +15,7 @@ namespace esphome::sun_gtil2 { -class SunGTIL2 : public Component, public uart::UARTDevice { +class SunGTIL2 final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index ed1f056c8b6..158fb08baff 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} @@ -16,7 +16,7 @@ template class TurnOnAction : public Action { Switch *switch_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} @@ -26,7 +26,7 @@ template class TurnOffAction : public Action { Switch *switch_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} @@ -36,7 +36,7 @@ template class ToggleAction : public Action { Switch *switch_; }; -template class ControlAction : public Action { +template class ControlAction final : public Action { public: explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} @@ -53,7 +53,7 @@ template class ControlAction : public Action { Switch *switch_; }; -template class SwitchCondition : public Condition { +template class SwitchCondition final : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -63,14 +63,14 @@ template class SwitchCondition : public Condition { bool state_; }; -class SwitchStateTrigger : public Trigger { +class SwitchStateTrigger final : public Trigger { public: SwitchStateTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class SwitchTurnOnTrigger : public Trigger<> { +class SwitchTurnOnTrigger final : public Trigger<> { public: SwitchTurnOnTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -81,7 +81,7 @@ class SwitchTurnOnTrigger : public Trigger<> { } }; -class SwitchTurnOffTrigger : public Trigger<> { +class SwitchTurnOffTrigger final : public Trigger<> { public: SwitchTurnOffTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -92,7 +92,7 @@ class SwitchTurnOffTrigger : public Trigger<> { } }; -template class SwitchPublishAction : public Action { +template class SwitchPublishAction final : public Action { public: SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} TEMPLATABLE_VALUE(bool, state) diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.h b/esphome/components/switch/binary_sensor/switch_binary_sensor.h index 0b77cdd9202..5c4184ecfaa 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.h +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -class SwitchBinarySensor : public binary_sensor::BinarySensor, public Component { +class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 2721cbfbbfa..4eb33abaa1e 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx126x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,23 +43,23 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, cold) void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } }; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 7590e35c28d..ccd20755e55 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx126x { -class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { +class SX126xTransport final : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 6816084df05..b3dfe6590a3 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -53,9 +53,9 @@ class SX126xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX126x : public Component, - public spi::SPIDevice { +class SX126x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index 7a2eb7ee8d9..f6a4537e231 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx127x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,22 +43,22 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(); } }; diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index 5dcfe02c339..fb38fc15bce 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx127x { -class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { +class SX127xTransport final : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 376c987ed15..070a6eeb96a 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -41,9 +41,9 @@ class SX127xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX127x : public Component, - public spi::SPIDevice { +class SX127x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h index bcd89015307..5d26a37283f 100644 --- a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h +++ b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h @@ -5,7 +5,7 @@ namespace esphome::sx1509 { -class SX1509BinarySensor : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { +class SX1509BinarySensor final : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { public: void set_row_col(uint8_t row, uint8_t col) { this->key_ = (1 << (col + 8)) | (1 << row); } void process(uint16_t data) override { this->publish_state(static_cast(data == key_)); } diff --git a/esphome/components/sx1509/output/sx1509_float_output.h b/esphome/components/sx1509/output/sx1509_float_output.h index ee53cef637a..8790b2fcd75 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.h +++ b/esphome/components/sx1509/output/sx1509_float_output.h @@ -7,7 +7,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509FloatOutputChannel : public output::FloatOutput, public Component { +class SX1509FloatOutputChannel final : public output::FloatOutput, public Component { public: void set_parent(SX1509Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 35883eed5be..c7aed2cddd1 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -28,12 +28,12 @@ class SX1509Processor { virtual void process(uint16_t data){}; }; -class SX1509KeyTrigger : public Trigger {}; +class SX1509KeyTrigger final : public Trigger {}; -class SX1509Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander, - public key_provider::KeyProvider { +class SX1509Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander, + public key_provider::KeyProvider { public: SX1509Component() = default; diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h index 9dcad37b272..3bd3d90bd93 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ b/esphome/components/sx1509/sx1509_gpio_pin.h @@ -6,7 +6,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509GPIOPin : public GPIOPin { +class SX1509GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h index 4a374d7e3d7..b94c89d123e 100644 --- a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h +++ b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { template -class StatusBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class StatusBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t value = (data.registers[REG] >> SHIFT) & MASK; @@ -24,7 +24,7 @@ class InverseStatusBinarySensor : public SY6970Listener, public binary_sensor::B }; // Custom binary sensor for charging (true when pre-charge or fast charge) -class SY6970ChargingBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class SY6970ChargingBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t chrg_stat = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; diff --git a/esphome/components/sy6970/sensor/sy6970_sensor.h b/esphome/components/sy6970/sensor/sy6970_sensor.h index f912d726b24..61abbc3e36b 100644 --- a/esphome/components/sy6970/sensor/sy6970_sensor.h +++ b/esphome/components/sy6970/sensor/sy6970_sensor.h @@ -34,7 +34,7 @@ using SY6970SystemVoltageSensor = VoltageSensor; // Precharge current sensor needs special handling (bit shift) -class SY6970PrechargeCurrentSensor : public SY6970Listener, public sensor::Sensor { +class SY6970PrechargeCurrentSensor final : public SY6970Listener, public sensor::Sensor { public: void on_data(const SY6970Data &data) override { uint8_t iprechg = (data.registers[SY6970_REG_PRECHARGE_CURRENT] >> 4) & 0x0F; diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 2225dd781b6..06f0615ab4b 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -73,7 +73,7 @@ class SY6970Listener { virtual void on_data(const SY6970Data &data) = 0; }; -class SY6970Component : public PollingComponent, public i2c::I2CDevice { +class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, uint16_t precharge_current, bool charge_enabled, bool enable_adc) diff --git a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h index 665c5eca643..e569bd0b902 100644 --- a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h +++ b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { // Bus status text sensor -class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970BusStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 5) & 0x07; @@ -40,7 +40,7 @@ class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::Tex }; // Charge status text sensor -class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970ChargeStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; @@ -66,7 +66,7 @@ class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor:: }; // NTC status text sensor -class SY6970NtcStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970NtcStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = data.registers[SY6970_REG_FAULT] & 0x07; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index be4fa91436f..4a76f9ac627 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,7 +7,7 @@ #ifdef USE_NETWORK namespace esphome::syslog { -class Syslog : public Component, public Parented { +class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} void setup() override; diff --git a/esphome/components/t6615/t6615.h b/esphome/components/t6615/t6615.h index 0c2088f7b04..7ad2ae23c7b 100644 --- a/esphome/components/t6615/t6615.h +++ b/esphome/components/t6615/t6615.h @@ -19,7 +19,7 @@ enum class T6615Command : uint8_t { SET_ELEVATION, }; -class T6615Component : public PollingComponent, public uart::UARTDevice { +class T6615Component final : public PollingComponent, public uart::UARTDevice { public: void loop() override; void update() override; diff --git a/esphome/components/tc74/tc74.h b/esphome/components/tc74/tc74.h index 4a53f39bc18..c48303c0096 100644 --- a/esphome/components/tc74/tc74.h +++ b/esphome/components/tc74/tc74.h @@ -6,7 +6,7 @@ namespace esphome::tc74 { -class TC74Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TC74Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: /// Setup the sensor and check connection. void setup() override; diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index f0417ac7f70..a98c226d322 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -8,7 +8,7 @@ namespace esphome::tca9548a { static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; -class TCA9548AChannel : public i2c::I2CBus { +class TCA9548AChannel final : public i2c::I2CBus { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(TCA9548AComponent *parent) { parent_ = parent; } @@ -21,7 +21,7 @@ class TCA9548AChannel : public i2c::I2CBus { TCA9548AComponent *parent_; }; -class TCA9548AComponent : public Component, public i2c::I2CDevice { +class TCA9548AComponent final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 19773a0e93c..50037cbe92d 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -7,9 +7,9 @@ namespace esphome::tca9555 { -class TCA9555Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class TCA9555Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: TCA9555Component() = default; @@ -47,7 +47,7 @@ class TCA9555Component : public Component, }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin : public GPIOPin, public Parented { +class TCA9555GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/tcl112/tcl112.h b/esphome/components/tcl112/tcl112.h index 0aef2decc82..21eb618947d 100644 --- a/esphome/components/tcl112/tcl112.h +++ b/esphome/components/tcl112/tcl112.h @@ -8,7 +8,7 @@ namespace esphome::tcl112 { const float TCL112_TEMP_MAX = 31.0; const float TCL112_TEMP_MIN = 16.0; -class Tcl112Climate : public climate_ir::ClimateIR { +class Tcl112Climate final : public climate_ir::ClimateIR { public: Tcl112Climate() : climate_ir::ClimateIR(TCL112_TEMP_MIN, TCL112_TEMP_MAX, .5f, true, true, diff --git a/esphome/components/tcs34725/tcs34725.h b/esphome/components/tcs34725/tcs34725.h index 15e4fae52ff..79b49bc8100 100644 --- a/esphome/components/tcs34725/tcs34725.h +++ b/esphome/components/tcs34725/tcs34725.h @@ -35,7 +35,7 @@ enum TCS34725Gain { TCS34725_GAIN_60X = 0x03, }; -class TCS34725Component : public PollingComponent, public i2c::I2CDevice { +class TCS34725Component final : public PollingComponent, public i2c::I2CDevice { public: void set_integration_time(TCS34725IntegrationTime integration_time); void set_gain(TCS34725Gain gain); diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index 4a082913188..bbd63a4e2b8 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -7,7 +7,7 @@ namespace esphome::tee501 { /// This class implements support for the tee501 of temperature i2c sensors. -class TEE501Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TEE501Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.h b/esphome/components/teleinfo/sensor/teleinfo_sensor.h index 37736c4e737..f4a27fa08be 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.h +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.h @@ -4,7 +4,7 @@ namespace esphome::teleinfo { -class TeleInfoSensor : public TeleInfoListener, public sensor::Sensor, public Component { +class TeleInfoSensor final : public TeleInfoListener, public sensor::Sensor, public Component { public: TeleInfoSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index eeab3b5103a..83ea1474f26 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -20,7 +20,7 @@ class TeleInfoListener { std::string tag; virtual void publish_val(const std::string &val){}; }; -class TeleInfo : public PollingComponent, public uart::UARTDevice { +class TeleInfo final : public PollingComponent, public uart::UARTDevice { public: TeleInfo(bool historical_mode); void register_teleinfo_listener(TeleInfoListener *listener); diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h index f4c04a03a03..24ec00e671a 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h @@ -3,7 +3,7 @@ #include "esphome/components/text_sensor/text_sensor.h" namespace esphome::teleinfo { -class TeleInfoTextSensor : public TeleInfoListener, public text_sensor::TextSensor, public Component { +class TeleInfoTextSensor final : public TeleInfoListener, public text_sensor::TextSensor, public Component { public: TeleInfoTextSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/tem3200/tem3200.h b/esphome/components/tem3200/tem3200.h index 5c73a25fbb4..ad8d0154f3e 100644 --- a/esphome/components/tem3200/tem3200.h +++ b/esphome/components/tem3200/tem3200.h @@ -7,7 +7,7 @@ namespace esphome::tem3200 { /// This class implements support for the tem3200 pressure and temperature i2c sensors. -class TEM3200Component : public PollingComponent, public i2c::I2CDevice { +class TEM3200Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { From bdd51bd4768e174e8e6ccb097530b1aab9f795f0 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:35 +1200 Subject: [PATCH 283/343] Mark configurable classes as final (16/21: sm10bit_base-ssd1331_spi) (#16967) --- .../components/sm10bit_base/sm10bit_base.h | 2 +- esphome/components/sm16716/sm16716.h | 4 +-- esphome/components/sm2135/sm2135.h | 4 +-- esphome/components/sm2235/sm2235.h | 2 +- esphome/components/sm2335/sm2335.h | 2 +- esphome/components/sm300d2/sm300d2.h | 2 +- esphome/components/sml/sensor/sml_sensor.h | 2 +- esphome/components/sml/sml.h | 2 +- .../sml/text_sensor/sml_text_sensor.h | 2 +- esphome/components/smt100/smt100.h | 2 +- esphome/components/sn74hc165/sn74hc165.h | 4 +-- esphome/components/sn74hc595/sn74hc595.h | 10 +++---- esphome/components/sntp/sntp_component.h | 2 +- esphome/components/sonoff_d1/sonoff_d1.h | 2 +- esphome/components/sound_level/sound_level.h | 6 ++-- esphome/components/spa06_i2c/spa06_i2c.h | 2 +- esphome/components/spa06_spi/spa06_spi.h | 6 ++-- esphome/components/speaker/automation.h | 16 +++++----- .../speaker/media_player/audio_pipeline.h | 2 +- .../speaker/media_player/automation.h | 3 +- .../media_player/speaker_media_player.h | 6 ++-- .../components/speaker_source/automation.h | 2 +- .../speaker_source_media_player.h | 2 +- esphome/components/speed/fan/speed_fan.h | 2 +- esphome/components/spi/spi.h | 2 +- esphome/components/spi_device/spi_device.h | 6 ++-- .../components/spi_led_strip/spi_led_strip.h | 6 ++-- esphome/components/sprinkler/automation.h | 30 +++++++++---------- esphome/components/sprinkler/sprinkler.h | 6 ++-- esphome/components/sps30/automation.h | 6 ++-- esphome/components/sps30/sps30.h | 2 +- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 +- esphome/components/ssd1306_spi/ssd1306_spi.h | 6 ++-- esphome/components/ssd1322_spi/ssd1322_spi.h | 6 ++-- esphome/components/ssd1325_spi/ssd1325_spi.h | 6 ++-- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 +- esphome/components/ssd1327_spi/ssd1327_spi.h | 6 ++-- esphome/components/ssd1331_spi/ssd1331_spi.h | 6 ++-- 38 files changed, 91 insertions(+), 90 deletions(-) diff --git a/esphome/components/sm10bit_base/sm10bit_base.h b/esphome/components/sm10bit_base/sm10bit_base.h index b419b86dbfa..a22c4da36e5 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.h +++ b/esphome/components/sm10bit_base/sm10bit_base.h @@ -27,7 +27,7 @@ class Sm10BitBase : public Component { void dump_config() override; void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(Sm10BitBase *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm16716/sm16716.h b/esphome/components/sm16716/sm16716.h index 09deb2e8bf8..8a76fd86f05 100644 --- a/esphome/components/sm16716/sm16716.h +++ b/esphome/components/sm16716/sm16716.h @@ -7,7 +7,7 @@ namespace esphome::sm16716 { -class SM16716 : public Component { +class SM16716 final : public Component { public: class Channel; @@ -25,7 +25,7 @@ class SM16716 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM16716 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index 040ec14b7fd..6bf77cf5541 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -21,7 +21,7 @@ enum SM2135Current : uint8_t { SM2135_CURRENT_60MA = 0x0A, }; -class SM2135 : public Component { +class SM2135 final : public Component { public: class Channel; @@ -49,7 +49,7 @@ class SM2135 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM2135 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2235/sm2235.h b/esphome/components/sm2235/sm2235.h index cdb754e2980..dbb51945f63 100644 --- a/esphome/components/sm2235/sm2235.h +++ b/esphome/components/sm2235/sm2235.h @@ -6,7 +6,7 @@ namespace esphome::sm2235 { -class SM2235 : public sm10bit_base::Sm10BitBase { +class SM2235 final : public sm10bit_base::Sm10BitBase { public: SM2235() = default; diff --git a/esphome/components/sm2335/sm2335.h b/esphome/components/sm2335/sm2335.h index 44e0e5b03ff..7c4f0269aa1 100644 --- a/esphome/components/sm2335/sm2335.h +++ b/esphome/components/sm2335/sm2335.h @@ -6,7 +6,7 @@ namespace esphome::sm2335 { -class SM2335 : public sm10bit_base::Sm10BitBase { +class SM2335 final : public sm10bit_base::Sm10BitBase { public: SM2335() = default; diff --git a/esphome/components/sm300d2/sm300d2.h b/esphome/components/sm300d2/sm300d2.h index 629e758e30d..87c60e92a11 100644 --- a/esphome/components/sm300d2/sm300d2.h +++ b/esphome/components/sm300d2/sm300d2.h @@ -6,7 +6,7 @@ namespace esphome::sm300d2 { -class SM300D2Sensor : public PollingComponent, public uart::UARTDevice { +class SM300D2Sensor final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } void set_formaldehyde_sensor(sensor::Sensor *formaldehyde_sensor) { formaldehyde_sensor_ = formaldehyde_sensor; } diff --git a/esphome/components/sml/sensor/sml_sensor.h b/esphome/components/sml/sensor/sml_sensor.h index d2f8a7743f0..a73af28f665 100644 --- a/esphome/components/sml/sensor/sml_sensor.h +++ b/esphome/components/sml/sensor/sml_sensor.h @@ -4,7 +4,7 @@ namespace esphome::sml { -class SmlSensor : public SmlListener, public sensor::Sensor, public Component { +class SmlSensor final : public SmlListener, public sensor::Sensor, public Component { public: SmlSensor(std::string server_id, std::string obis_code); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/sml/sml.h b/esphome/components/sml/sml.h index 60a80e3ad84..b59526648d4 100644 --- a/esphome/components/sml/sml.h +++ b/esphome/components/sml/sml.h @@ -17,7 +17,7 @@ class SmlListener { virtual void publish_val(const ObisInfo &obis_info){}; }; -class Sml : public Component, public uart::UARTDevice { +class Sml final : public Component, public uart::UARTDevice { public: void register_sml_listener(SmlListener *listener); void loop() override; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.h b/esphome/components/sml/text_sensor/sml_text_sensor.h index 6194f223493..d445d514e9b 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.h +++ b/esphome/components/sml/text_sensor/sml_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sml { -class SmlTextSensor : public SmlListener, public text_sensor::TextSensor, public Component { +class SmlTextSensor final : public SmlListener, public text_sensor::TextSensor, public Component { public: SmlTextSensor(std::string server_id, std::string obis_code, SmlType format); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index b68151eeb40..55977a5caf6 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -6,7 +6,7 @@ namespace esphome::smt100 { -class SMT100Component : public PollingComponent, public uart::UARTDevice { +class SMT100Component final : public PollingComponent, public uart::UARTDevice { static const uint16_t MAX_LINE_LENGTH = 31; public: diff --git a/esphome/components/sn74hc165/sn74hc165.h b/esphome/components/sn74hc165/sn74hc165.h index 596f2eb4f59..9e80aa67bf9 100644 --- a/esphome/components/sn74hc165/sn74hc165.h +++ b/esphome/components/sn74hc165/sn74hc165.h @@ -8,7 +8,7 @@ namespace esphome::sn74hc165 { -class SN74HC165Component : public Component { +class SN74HC165Component final : public Component { public: SN74HC165Component() = default; @@ -40,7 +40,7 @@ class SN74HC165Component : public Component { }; /// Helper class to expose a SC74HC165 pin as an internal input GPIO pin. -class SN74HC165GPIOPin : public GPIOPin, public Parented { +class SN74HC165GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} diff --git a/esphome/components/sn74hc595/sn74hc595.h b/esphome/components/sn74hc595/sn74hc595.h index 23977e3d04c..0b291b9ee55 100644 --- a/esphome/components/sn74hc595/sn74hc595.h +++ b/esphome/components/sn74hc595/sn74hc595.h @@ -47,7 +47,7 @@ class SN74HC595Component : public Component { }; /// Helper class to expose a SC74HC595 pin as an internal output GPIO pin. -class SN74HC595GPIOPin : public GPIOPin, public Parented { +class SN74HC595GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} @@ -66,7 +66,7 @@ class SN74HC595GPIOPin : public GPIOPin, public Parented { bool inverted_; }; -class SN74HC595GPIOComponent : public SN74HC595Component { +class SN74HC595GPIOComponent final : public SN74HC595Component { public: void setup() override; void set_data_pin(GPIOPin *pin) { data_pin_ = pin; } @@ -80,9 +80,9 @@ class SN74HC595GPIOComponent : public SN74HC595Component { }; #ifdef USE_SPI -class SN74HC595SPIComponent : public SN74HC595Component, - public spi::SPIDevice { +class SN74HC595SPIComponent final : public SN74HC595Component, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index ef737c1978c..686fb30d253 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -15,7 +15,7 @@ namespace esphome::sntp { /// The C library (newlib) available on ESPs only supports TZ strings that specify an offset and DST info; /// you cannot specify zone names or paths to zoneinfo files. /// \see https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html -class SNTPComponent : public time::RealTimeClock { +class SNTPComponent final : public time::RealTimeClock { public: SNTPComponent(const std::array &servers) : servers_(servers) {} diff --git a/esphome/components/sonoff_d1/sonoff_d1.h b/esphome/components/sonoff_d1/sonoff_d1.h index a92877e6c8f..b7fcb1efa73 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.h +++ b/esphome/components/sonoff_d1/sonoff_d1.h @@ -41,7 +41,7 @@ namespace esphome::sonoff_d1 { -class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, public Component { +class SonoffD1Output final : public light::LightOutput, public uart::UARTDevice, public Component { public: // LightOutput methods light::LightTraits get_traits() override; diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index aabea62ca42..94c18421baf 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -12,7 +12,7 @@ namespace esphome::sound_level { -class SoundLevelComponent : public Component { +class SoundLevelComponent final : public Component { public: void dump_config() override; void setup() override; @@ -59,12 +59,12 @@ class SoundLevelComponent : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; diff --git a/esphome/components/spa06_i2c/spa06_i2c.h b/esphome/components/spa06_i2c/spa06_i2c.h index 6b4bce3a4ec..05e60cbb5d2 100644 --- a/esphome/components/spa06_i2c/spa06_i2c.h +++ b/esphome/components/spa06_i2c/spa06_i2c.h @@ -4,7 +4,7 @@ namespace esphome::spa06_i2c { -class SPA06I2CComponent : public spa06_base::SPA06Component, public i2c::I2CDevice { +class SPA06I2CComponent final : public spa06_base::SPA06Component, public i2c::I2CDevice { public: bool spa_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool spa_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/spa06_spi/spa06_spi.h b/esphome/components/spa06_spi/spa06_spi.h index ffbc162d6fe..56d72df6202 100644 --- a/esphome/components/spa06_spi/spa06_spi.h +++ b/esphome/components/spa06_spi/spa06_spi.h @@ -5,9 +5,9 @@ namespace esphome::spa06_spi { -class SPA06SPIComponent : public spa06_base::SPA06Component, - public spi::SPIDevice { +class SPA06SPIComponent final : public spa06_base::SPA06Component, + public spi::SPIDevice { void setup() override; bool spa_read_byte(uint8_t a_register, uint8_t *data) override; bool spa_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 9997b064d50..443588a04c1 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -7,7 +7,7 @@ namespace esphome::speaker { -template class PlayAction : public Action, public Parented { +template class PlayAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -38,12 +38,12 @@ template class PlayAction : public Action, public Parente } data_; }; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} @@ -53,7 +53,7 @@ template class MuteOnAction : public Action { Speaker *speaker_; }; -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} @@ -63,22 +63,22 @@ template class MuteOffAction : public Action { Speaker *speaker_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class FinishAction : public Action, public Parented { +template class FinishAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsStoppedCondition : public Condition, public Parented { +template class IsStoppedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_stopped(); } }; diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 89f4707ab3c..02dad15de97 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -56,7 +56,7 @@ struct InfoErrorEvent { optional decoding_err; }; -class AudioPipeline { +class AudioPipeline final { public: /// @param speaker ESPHome speaker component for pipeline's audio output /// @param buffer_size Size of the buffer in bytes between the reader and decoder diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h index 7843399866f..f9e21279939 100644 --- a/esphome/components/speaker/media_player/automation.h +++ b/esphome/components/speaker/media_player/automation.h @@ -9,7 +9,8 @@ namespace esphome::speaker { -template class PlayOnDeviceMediaAction : public Action, public Parented { +template +class PlayOnDeviceMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) TEMPLATABLE_VALUE(bool, announcement) TEMPLATABLE_VALUE(bool, enqueue) diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 2d80377312e..6470fb925c2 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -42,11 +42,11 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerMediaPlayer : public Component, - public media_player::MediaPlayer +class SpeakerMediaPlayer final : public Component, + public media_player::MediaPlayer #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h index b436149a03f..a03fa424777 100644 --- a/esphome/components/speaker_source/automation.h +++ b/esphome/components/speaker_source/automation.h @@ -9,7 +9,7 @@ namespace esphome::speaker_source { -template class SetPlaylistDelayAction : public Action { +template class SetPlaylistDelayAction final : public Action { public: explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 652390edd22..ab1f8edfed7 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -146,7 +146,7 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPlayer { +class SpeakerSourceMediaPlayer final : public Component, public media_player::MediaPlayer { friend struct SourceBinding; public: diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index c618d6bc5f6..510b3e9621d 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -7,7 +7,7 @@ namespace esphome::speed { -class SpeedFan : public Component, public fan::Fan { +class SpeedFan final : public Component, public fan::Fan { public: SpeedFan(int speed_count) : speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index e6f592c6e44..cada29b0d7d 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -334,7 +334,7 @@ class SPIBus { class SPIClient; -class SPIComponent : public Component { +class SPIComponent final : public Component { public: SPIDelegate *register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only); diff --git a/esphome/components/spi_device/spi_device.h b/esphome/components/spi_device/spi_device.h index 3a2523fbab0..506090fc58d 100644 --- a/esphome/components/spi_device/spi_device.h +++ b/esphome/components/spi_device/spi_device.h @@ -5,9 +5,9 @@ namespace esphome::spi_device { -class SPIDeviceComponent : public Component, - public spi::SPIDevice { +class SPIDeviceComponent final : public Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index e2bcd5af639..20b9c25c2e9 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -8,9 +8,9 @@ namespace esphome::spi_led_strip { static const char *const TAG = "spi_led_strip"; -class SpiLedStrip : public light::AddressableLight, - public spi::SPIDevice { +class SpiLedStrip final : public light::AddressableLight, + public spi::SPIDevice { public: SpiLedStrip(uint16_t num_leds); void setup() override; diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index c6fe2e4e022..beeec96b98a 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,7 +6,7 @@ namespace esphome::sprinkler { -template class SetDividerAction : public Action { +template class SetDividerAction final : public Action { public: explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -18,7 +18,7 @@ template class SetDividerAction : public Action { Sprinkler *sprinkler_; }; -template class SetMultiplierAction : public Action { +template class SetMultiplierAction final : public Action { public: explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -30,7 +30,7 @@ template class SetMultiplierAction : public Action { Sprinkler *sprinkler_; }; -template class QueueValveAction : public Action { +template class QueueValveAction final : public Action { public: explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -46,7 +46,7 @@ template class QueueValveAction : public Action { Sprinkler *sprinkler_; }; -template class ClearQueuedValvesAction : public Action { +template class ClearQueuedValvesAction final : public Action { public: explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -56,7 +56,7 @@ template class ClearQueuedValvesAction : public Action { Sprinkler *sprinkler_; }; -template class SetRepeatAction : public Action { +template class SetRepeatAction final : public Action { public: explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -68,7 +68,7 @@ template class SetRepeatAction : public Action { Sprinkler *sprinkler_; }; -template class SetRunDurationAction : public Action { +template class SetRunDurationAction final : public Action { public: explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -84,7 +84,7 @@ template class SetRunDurationAction : public Action { Sprinkler *sprinkler_; }; -template class StartFromQueueAction : public Action { +template class StartFromQueueAction final : public Action { public: explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -94,7 +94,7 @@ template class StartFromQueueAction : public Action { Sprinkler *sprinkler_; }; -template class StartFullCycleAction : public Action { +template class StartFullCycleAction final : public Action { public: explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -104,7 +104,7 @@ template class StartFullCycleAction : public Action { Sprinkler *sprinkler_; }; -template class StartSingleValveAction : public Action { +template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -122,7 +122,7 @@ template class StartSingleValveAction : public Action { TemplatableValue valve_to_start_{}; }; -template class ShutdownAction : public Action { +template class ShutdownAction final : public Action { public: explicit ShutdownAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -132,7 +132,7 @@ template class ShutdownAction : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction : public Action { +template class NextValveAction final : public Action { public: explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -142,7 +142,7 @@ template class NextValveAction : public Action { Sprinkler *sprinkler_; }; -template class PreviousValveAction : public Action { +template class PreviousValveAction final : public Action { public: explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -152,7 +152,7 @@ template class PreviousValveAction : public Action { Sprinkler *sprinkler_; }; -template class PauseAction : public Action { +template class PauseAction final : public Action { public: explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -162,7 +162,7 @@ template class PauseAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeAction : public Action { +template class ResumeAction final : public Action { public: explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -172,7 +172,7 @@ template class ResumeAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeOrStartAction : public Action { +template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2598a5606a7..bd610f7ad3e 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -70,7 +70,7 @@ struct SprinklerValve { std::unique_ptr> valve_turn_on_automation; }; -class SprinklerControllerNumber : public number::Number, public Component { +class SprinklerControllerNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; @@ -89,7 +89,7 @@ class SprinklerControllerNumber : public number::Number, public Component { ESPPreferenceObject pref_; }; -class SprinklerControllerSwitch : public switch_::Switch, public Component { +class SprinklerControllerSwitch final : public switch_::Switch, public Component { public: SprinklerControllerSwitch(); @@ -173,7 +173,7 @@ class SprinklerValveRunRequest { SprinklerValveRunRequestOrigin origin_{USER}; }; -class Sprinkler : public Component { +class Sprinkler final : public Component { public: Sprinkler(); Sprinkler(const char *name); diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index e58f857eb3a..ba978e7770b 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -6,17 +6,17 @@ namespace esphome::sps30 { -template class StartFanAction : public Action, public Parented { +template class StartFanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } }; -template class StartMeasurementAction : public Action, public Parented { +template class StartMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_measurement(); } }; -template class StopMeasurementAction : public Action, public Parented { +template class StopMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index ccb3e8ff413..10b89c844bc 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -8,7 +8,7 @@ namespace esphome::sps30 { /// This class implements support for the Sensirion SPS30 i2c/UART Particulate Matter /// PM1.0, PM2.5, PM4, PM10 Air Quality sensors. -class SPS30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SPS30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_pm_1_0_sensor(sensor::Sensor *pm_1_0) { pm_1_0_sensor_ = pm_1_0; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5) { pm_2_5_sensor_ = pm_2_5; } diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 0316da0e778..54c7d862870 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1306_i2c { -class I2CSSD1306 : public ssd1306_base::SSD1306, public i2c::I2CDevice { +class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.h b/esphome/components/ssd1306_spi/ssd1306_spi.h index f8346033b32..948d099d0f7 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.h +++ b/esphome/components/ssd1306_spi/ssd1306_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1306_spi { -class SPISSD1306 : public ssd1306_base::SSD1306, - public spi::SPIDevice { +class SPISSD1306 final : public ssd1306_base::SSD1306, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.h b/esphome/components/ssd1322_spi/ssd1322_spi.h index 31d17d0ef1a..1ac9654109e 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.h +++ b/esphome/components/ssd1322_spi/ssd1322_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1322_spi { -class SPISSD1322 : public ssd1322_base::SSD1322, - public spi::SPIDevice { +class SPISSD1322 final : public ssd1322_base::SSD1322, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.h b/esphome/components/ssd1325_spi/ssd1325_spi.h index 32cbb28fd85..3202eabec57 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.h +++ b/esphome/components/ssd1325_spi/ssd1325_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1325_spi { -class SPISSD1325 : public ssd1325_base::SSD1325, - public spi::SPIDevice { +class SPISSD1325 final : public ssd1325_base::SSD1325, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index f08ef94fefa..75f854d3da3 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1327_i2c { -class I2CSSD1327 : public ssd1327_base::SSD1327, public i2c::I2CDevice { +class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.h b/esphome/components/ssd1327_spi/ssd1327_spi.h index fd1ed0357f3..cb7d5e2181e 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.h +++ b/esphome/components/ssd1327_spi/ssd1327_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1327_spi { -class SPISSD1327 : public ssd1327_base::SSD1327, - public spi::SPIDevice { +class SPISSD1327 final : public ssd1327_base::SSD1327, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.h b/esphome/components/ssd1331_spi/ssd1331_spi.h index acdc004b26a..add010712c8 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.h +++ b/esphome/components/ssd1331_spi/ssd1331_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1331_spi { -class SPISSD1331 : public ssd1331_base::SSD1331, - public spi::SPIDevice { +class SPISSD1331 final : public ssd1331_base::SSD1331, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } From cfdd6d383f3d074a9730ed41f1d55baf5d9534ee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:32:42 +1200 Subject: [PATCH 284/343] Mark configurable classes as final (19/21: uart-wl_134) (#16970) --- esphome/components/uart/automation.h | 2 +- esphome/components/uart/button/uart_button.h | 2 +- esphome/components/uart/event/uart_event.h | 2 +- .../uart/packet_transport/uart_transport.h | 2 +- esphome/components/uart/switch/uart_switch.h | 2 +- .../components/uart/uart_component_esp8266.h | 2 +- .../components/uart/uart_component_esp_idf.h | 2 +- esphome/components/uart/uart_component_host.h | 2 +- .../components/uart/uart_component_libretiny.h | 2 +- .../components/uart/uart_component_rp2040.h | 2 +- esphome/components/uart/uart_debugger.h | 4 ++-- esphome/components/udp/automation.h | 2 +- .../udp/packet_transport/udp_transport.h | 2 +- esphome/components/udp/udp_component.h | 2 +- esphome/components/ufire_ec/ufire_ec.h | 6 +++--- esphome/components/ufire_ise/ufire_ise.h | 8 ++++---- esphome/components/uln2003/uln2003.h | 2 +- .../components/ultrasonic/ultrasonic_sensor.h | 2 +- esphome/components/update/automation.h | 6 +++--- .../climate/uponor_smatrix_climate.h | 2 +- .../sensor/uponor_smatrix_sensor.h | 2 +- .../components/uponor_smatrix/uponor_smatrix.h | 2 +- .../uptime/sensor/uptime_seconds_sensor.h | 2 +- .../uptime/sensor/uptime_timestamp_sensor.h | 2 +- .../uptime/text_sensor/uptime_text_sensor.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 4 ++-- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_uart/usb_uart.h | 2 +- esphome/components/valve/automation.h | 18 +++++++++--------- .../vbus/binary_sensor/vbus_binary_sensor.h | 18 +++++++++--------- esphome/components/vbus/vbus.h | 2 +- esphome/components/veml3235/veml3235.h | 2 +- esphome/components/veml7700/veml7700.h | 2 +- esphome/components/vl53l0x/vl53l0x_sensor.h | 2 +- .../voice_assistant/voice_assistant.h | 12 ++++++------ esphome/components/wake_on_lan/wake_on_lan.h | 2 +- .../web_server_base/web_server_base.h | 2 +- esphome/components/weikai_i2c/weikai_i2c.h | 2 +- esphome/components/weikai_spi/weikai_spi.h | 6 +++--- esphome/components/whirlpool/whirlpool.h | 2 +- esphome/components/whynter/whynter.h | 2 +- esphome/components/wiegand/wiegand.h | 8 ++++---- esphome/components/wifi/automation.h | 12 ++++++------ .../wifi_signal/wifi_signal_sensor.h | 4 ++-- esphome/components/wireguard/wireguard.h | 11 ++++++----- esphome/components/wl_134/wl_134.h | 2 +- 46 files changed, 92 insertions(+), 91 deletions(-) diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index c99caac97b2..e5a9fa7c7bc 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -7,7 +7,7 @@ namespace esphome::uart { -template class UARTWriteAction : public Action, public Parented { +template class UARTWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 2b530d3c4bc..47f45d4899c 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class UARTButton : public button::Button, public UARTDevice, public Component { +class UARTButton final : public button::Button, public UARTDevice, public Component { public: void set_data(std::vector &&data) { this->data_ = std::move(data); } void set_data(std::initializer_list data) { this->data_ = std::vector(data); } diff --git a/esphome/components/uart/event/uart_event.h b/esphome/components/uart/event/uart_event.h index 8a00b5894ba..3960ffd5bb8 100644 --- a/esphome/components/uart/event/uart_event.h +++ b/esphome/components/uart/event/uart_event.h @@ -7,7 +7,7 @@ namespace esphome::uart { -class UARTEvent : public event::Event, public UARTDevice, public Component { +class UARTEvent final : public event::Event, public UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index 1c92af536ef..b1ce8ac5908 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -20,7 +20,7 @@ static const uint16_t MAX_PACKET_SIZE = 508; static const uint8_t FLAG_BYTE = 0x7E; static const uint8_t CONTROL_BYTE = 0x7D; -class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { +class UARTTransport final : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 5730fc9b4b2..c924c7d4e54 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -9,7 +9,7 @@ namespace esphome::uart { -class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { +class UARTSwitch final : public switch_::Switch, public UARTDevice, public Component { public: void loop() override; diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 7f844d9b651..ee3be3cd3a1 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -46,7 +46,7 @@ class ESP8266SoftwareSerial { ISRInternalGPIOPin rx_pin_; }; -class ESP8266UartComponent : public UARTComponent, public Component { +class ESP8266UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index ec4f2884b29..3b86368797d 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -16,7 +16,7 @@ namespace esphome::uart { /// Thread safety: All public methods must only be called from the main loop. /// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's /// peek byte state (has_peek_/peek_byte_) is not synchronized. -class IDFUARTComponent : public UARTComponent, public Component { +class IDFUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index a47e5649be8..bca62debf10 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class HostUartComponent : public UARTComponent, public Component { +class HostUartComponent final : public UARTComponent, public Component { public: virtual ~HostUartComponent(); void setup() override; diff --git a/esphome/components/uart/uart_component_libretiny.h b/esphome/components/uart/uart_component_libretiny.h index 872ea866014..aa13a01392a 100644 --- a/esphome/components/uart/uart_component_libretiny.h +++ b/esphome/components/uart/uart_component_libretiny.h @@ -10,7 +10,7 @@ namespace esphome::uart { -class LibreTinyUARTComponent : public UARTComponent, public Component { +class LibreTinyUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2040.h index 198c698af9c..b16d8b12d95 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2040.h @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent : public UARTComponent, public Component { +class RP2040UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_debugger.h b/esphome/components/uart/uart_debugger.h index da33bea70c0..b69dcf0676c 100644 --- a/esphome/components/uart/uart_debugger.h +++ b/esphome/components/uart/uart_debugger.h @@ -18,7 +18,7 @@ namespace esphome::uart { /// 'appropriate time' means exactly, is determined by a number of /// configurable constraints. E.g. when a given number of bytes is gathered /// and/or when no more data has been seen for a given time interval. -class UARTDebugger : public Component, public Trigger, StringRef> { +class UARTDebugger final : public Component, public Trigger, StringRef> { public: explicit UARTDebugger(UARTComponent *parent); void loop() override; @@ -73,7 +73,7 @@ class UARTDebugger : public Component, public Trigger class UDPWriteAction : public Action, public Parented { +template class UDPWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index 8621ddca482..e91a3e2a5ac 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -8,7 +8,7 @@ namespace esphome::udp { -class UDPTransport : public packet_transport::PacketTransport, public Parented { +class UDPTransport final : public packet_transport::PacketTransport, public Parented { public: void setup() override; diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index fb0edf2ebd7..274e0119ee5 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -18,7 +18,7 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; -class UDPComponent : public Component { +class UDPComponent final : public Component { public: void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } /// Prevent accidental use of std::string which would dangle diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index fce62586322..0928fda9ee2 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -24,7 +24,7 @@ static const uint8_t COMMAND_CALIBRATE_PROBE = 20; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_EC = 80; -class UFireECComponent : public PollingComponent, public i2c::I2CDevice { +class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireECComponent : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction : public Action { +template class UFireECCalibrateProbeAction final : public Action { public: UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -72,7 +72,7 @@ template class UFireECCalibrateProbeAction : public Action class UFireECResetAction : public Action { +template class UFireECResetAction final : public Action { public: UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index bff8eeff9de..85916f227e5 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -29,7 +29,7 @@ static const uint8_t COMMAND_CALIBRATE_LOW = 10; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_MV = 80; -class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { +class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction : public Action { +template class UFireISECalibrateProbeLowAction final : public Action { public: UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -69,7 +69,7 @@ template class UFireISECalibrateProbeLowAction : public Action class UFireISECalibrateProbeHighAction : public Action { +template class UFireISECalibrateProbeHighAction final : public Action { public: UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -80,7 +80,7 @@ template class UFireISECalibrateProbeHighAction : public Action< UFireISEComponent *parent_; }; -template class UFireISEResetAction : public Action { +template class UFireISEResetAction final : public Action { public: UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} diff --git a/esphome/components/uln2003/uln2003.h b/esphome/components/uln2003/uln2003.h index 70f55f72bfd..1b1a16f95e2 100644 --- a/esphome/components/uln2003/uln2003.h +++ b/esphome/components/uln2003/uln2003.h @@ -12,7 +12,7 @@ enum ULN2003StepMode { ULN2003_STEP_MODE_WAVE_DRIVE, }; -class ULN2003 : public stepper::Stepper, public Component { +class ULN2003 final : public stepper::Stepper, public Component { public: void set_pin_a(GPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(GPIOPin *pin_b) { pin_b_ = pin_b; } diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.h b/esphome/components/ultrasonic/ultrasonic_sensor.h index 7d333a1b243..ea8fcbf72ee 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.h +++ b/esphome/components/ultrasonic/ultrasonic_sensor.h @@ -18,7 +18,7 @@ struct UltrasonicSensorStore { volatile bool echo_end{false}; }; -class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent { +class UltrasonicSensorComponent final : public sensor::Sensor, public PollingComponent { public: void set_trigger_pin(InternalGPIOPin *trigger_pin) { this->trigger_pin_ = trigger_pin; } void set_echo_pin(InternalGPIOPin *echo_pin) { this->echo_pin_ = echo_pin; } diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 821151f67ce..8ba7b71a9ca 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -6,19 +6,19 @@ namespace esphome::update { -template class PerformAction : public Action, public Parented { +template class PerformAction final : public Action, public Parented { TEMPLATABLE_VALUE(bool, force) public: void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } }; -template class CheckAction : public Action, public Parented { +template class CheckAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->check(); } }; -template class IsAvailableCondition : public Condition, public Parented { +template class IsAvailableCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } }; diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h index 4cc5a4a3bcf..47556557477 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixClimate : public climate::Climate, public Component, public UponorSmatrixDevice { +class UponorSmatrixClimate final : public climate::Climate, public Component, public UponorSmatrixDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h index 346fe1e3d66..b507642fce4 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixSensor : public sensor::Sensor, public Component, public UponorSmatrixDevice { +class UponorSmatrixSensor final : public sensor::Sensor, public Component, public UponorSmatrixDevice { SUB_SENSOR(temperature) SUB_SENSOR(external_temperature) SUB_SENSOR(humidity) diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index e9e772feaba..8476c6bac24 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -62,7 +62,7 @@ struct UponorSmatrixData { class UponorSmatrixDevice; -class UponorSmatrixComponent : public uart::UARTDevice, public Component { +class UponorSmatrixComponent final : public uart::UARTDevice, public Component { public: UponorSmatrixComponent() = default; diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index 1b80a4480af..b0b12954b28 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -5,7 +5,7 @@ namespace esphome::uptime { -class UptimeSecondsSensor : public sensor::Sensor, public PollingComponent { +class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h index 912c0b76555..5b837cbce17 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h @@ -10,7 +10,7 @@ namespace esphome::uptime { -class UptimeTimestampSensor : public sensor::Sensor, public Component { +class UptimeTimestampSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index a97ba332bbe..0bdc7fe404a 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::uptime { -class UptimeTextSensor : public text_sensor::TextSensor, public PollingComponent { +class UptimeTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: UptimeTextSensor(const char *days_text, const char *hours_text, const char *minutes_text, const char *seconds_text, const char *separator, bool expand) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 10692fd436e..2251c600e7e 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -51,7 +51,7 @@ struct CDCEvent { class USBCDCACMComponent; /// Represents a single CDC ACM interface instance -class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBCDCACMInstance final : public uart::UARTComponent, public Parented { public: void setup(); void loop(); @@ -112,7 +112,7 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBUartChannel final : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 08c9f4e0110..63d03a889b3 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::valve { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Valve *valve) : valve_(valve) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Valve *valve_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Valve *valve) : valve_(valve) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Valve *valve_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Valve *valve) : valve_(valve) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Valve *valve_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Valve *valve) : valve_(valve) {} @@ -58,7 +58,7 @@ template class ToggleAction : public Action { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} @@ -74,7 +74,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class ValveIsOpenCondition : public Condition { +template class ValveIsOpenCondition final : public Condition { public: ValveIsOpenCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } @@ -83,7 +83,7 @@ template class ValveIsOpenCondition : public Condition { Valve *valve_; }; -template class ValveIsClosedCondition : public Condition { +template class ValveIsClosedCondition final : public Condition { public: ValveIsClosedCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } @@ -92,7 +92,7 @@ template class ValveIsClosedCondition : public Condition Valve *valve_; }; -class ValveOpenTrigger : public Trigger<> { +class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { @@ -106,7 +106,7 @@ class ValveOpenTrigger : public Trigger<> { Valve *valve_; }; -class ValveClosedTrigger : public Trigger<> { +class ValveClosedTrigger final : public Trigger<> { public: ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 8d372f45d63..a77fc7f56a7 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::vbus { -class DeltaSolBSPlusBSensor : public VBusListener, public Component { +class DeltaSolBSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_relay1_bsensor(binary_sensor::BinarySensor *bsensor) { this->relay1_bsensor_ = bsensor; } @@ -38,7 +38,7 @@ class DeltaSolBSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2009BSensor : public VBusListener, public Component { +class DeltaSolBS2009BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -59,7 +59,7 @@ class DeltaSolBS2009BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCBSensor : public VBusListener, public Component { +class DeltaSolCBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -76,7 +76,7 @@ class DeltaSolCBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS2BSensor : public VBusListener, public Component { +class DeltaSolCS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -93,7 +93,7 @@ class DeltaSolCS2BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS4BSensor : public VBusListener, public Component { +class DeltaSolCS4BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -110,7 +110,7 @@ class DeltaSolCS4BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCSPlusBSensor : public VBusListener, public Component { +class DeltaSolCSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -127,7 +127,7 @@ class DeltaSolCSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2BSensor : public VBusListener, public Component { +class DeltaSolBS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -146,7 +146,7 @@ class DeltaSolBS2BSensor : public VBusListener, public Component { class VBusCustomSubBSensor; -class VBusCustomBSensor : public VBusListener, public Component { +class VBusCustomBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_bsensors(std::vector bsensors) { this->bsensors_ = std::move(bsensors); }; @@ -156,7 +156,7 @@ class VBusCustomBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class VBusCustomSubBSensor : public binary_sensor::BinarySensor, public Component { +class VBusCustomSubBSensor final : public binary_sensor::BinarySensor, public Component { public: void set_message_parser(message_parser_t parser) { this->message_parser_ = std::move(parser); }; void parse_message(std::vector &message); diff --git a/esphome/components/vbus/vbus.h b/esphome/components/vbus/vbus.h index ff523178ef6..c8cd0cb4a43 100644 --- a/esphome/components/vbus/vbus.h +++ b/esphome/components/vbus/vbus.h @@ -25,7 +25,7 @@ class VBusListener { virtual void handle_message(std::vector &message) = 0; }; -class VBus : public uart::UARTDevice, public Component { +class VBus final : public uart::UARTDevice, public Component { public: void dump_config() override; void loop() override; diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index df88bc6ff57..cda6d177aa4 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -59,7 +59,7 @@ enum VEML3235ComponentGain { VEML3235_GAIN_4X = 0b11, }; -class VEML3235Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index a036bdf0029..4a1e25fb8af 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -95,7 +95,7 @@ union PSMRegister { } __attribute__((packed)); }; -class VEML7700Component : public PollingComponent, public i2c::I2CDevice { +class VEML7700Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.h b/esphome/components/vl53l0x/vl53l0x_sensor.h index 7c916f4fdeb..0aa01685c40 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.h +++ b/esphome/components/vl53l0x/vl53l0x_sensor.h @@ -22,7 +22,7 @@ struct SequenceStepTimeouts { enum VcselPeriodType { VCSEL_PERIOD_PRE_RANGE, VCSEL_PERIOD_FINAL_RANGE }; -class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VL53L0XSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: VL53L0XSensor(); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 76b076a366b..dd9d205afff 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -110,7 +110,7 @@ enum class MediaPlayerResponseState { }; #endif -class VoiceAssistant : public Component { +class VoiceAssistant final : public Component { public: VoiceAssistant(); @@ -353,7 +353,7 @@ class VoiceAssistant : public Component { #endif }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, wake_word); public: @@ -368,22 +368,22 @@ template class StartAction : public Action, public Parent bool silence_detection_; }; -template class StartContinuousAction : public Action, public Parented { +template class StartContinuousAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_start(true, true); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } }; -template class ConnectedCondition : public Condition, public Parented { +template class ConnectedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } }; diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 84bc26e0649..ddf3433e7d4 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -11,7 +11,7 @@ namespace esphome::wake_on_lan { -class WakeOnLanButton : public button::Button, public Component { +class WakeOnLanButton final : public button::Button, public Component { public: void set_macaddr(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e, uint8_t f); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c7162c139a9..19c2185fb95 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -88,7 +88,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { } // namespace internal -class WebServerBase { +class WebServerBase final { public: void init() { if (this->initialized_) { diff --git a/esphome/components/weikai_i2c/weikai_i2c.h b/esphome/components/weikai_i2c/weikai_i2c.h index 940dbad9f26..6d8da031ac4 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.h +++ b/esphome/components/weikai_i2c/weikai_i2c.h @@ -38,7 +38,7 @@ class WeikaiRegisterI2C : public weikai::WeikaiRegister { /// @brief The WeikaiComponentI2C class stores the information to the WeiKai component /// connected through an I2C bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentI2C : public weikai::WeikaiComponent, public i2c::I2CDevice { +class WeikaiComponentI2C final : public weikai::WeikaiComponent, public i2c::I2CDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_i2c_.register_ = reg; diff --git a/esphome/components/weikai_spi/weikai_spi.h b/esphome/components/weikai_spi/weikai_spi.h index 3b581ef44ce..cdfa148c240 100644 --- a/esphome/components/weikai_spi/weikai_spi.h +++ b/esphome/components/weikai_spi/weikai_spi.h @@ -31,9 +31,9 @@ class WeikaiRegisterSPI : public weikai::WeikaiRegister { /// @brief The WeikaiComponentSPI class stores the information to the WeiKai component /// connected through an SPI bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentSPI : public weikai::WeikaiComponent, - public spi::SPIDevice { +class WeikaiComponentSPI final : public weikai::WeikaiComponent, + public spi::SPIDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_spi_.register_ = reg; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index 03b4cf21a82..b705ee95fa7 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -16,7 +16,7 @@ const float WHIRLPOOL_DG11J1_3A_TEMP_MIN = 18.0; const float WHIRLPOOL_DG11J1_91_TEMP_MAX = 30.0; const float WHIRLPOOL_DG11J1_91_TEMP_MIN = 16.0; -class WhirlpoolClimate : public climate_ir::ClimateIR { +class WhirlpoolClimate final : public climate_ir::ClimateIR { public: WhirlpoolClimate(); diff --git a/esphome/components/whynter/whynter.h b/esphome/components/whynter/whynter.h index d67bfa8fa03..fa8f201b050 100644 --- a/esphome/components/whynter/whynter.h +++ b/esphome/components/whynter/whynter.h @@ -12,7 +12,7 @@ const uint8_t TEMP_MAX_C = 32; // Celsius const uint8_t TEMP_MIN_F = 61; // Fahrenheit const uint8_t TEMP_MAX_F = 89; // Fahrenheit -class Whynter : public climate_ir::ClimateIR { +class Whynter final : public climate_ir::ClimateIR { public: Whynter() : climate_ir::ClimateIR(TEMP_MIN_C, TEMP_MAX_C, 1.0, true, true, diff --git a/esphome/components/wiegand/wiegand.h b/esphome/components/wiegand/wiegand.h index 33d81ba086b..079f02ed686 100644 --- a/esphome/components/wiegand/wiegand.h +++ b/esphome/components/wiegand/wiegand.h @@ -21,13 +21,13 @@ struct WiegandStore { static void d1_gpio_intr(WiegandStore *arg); }; -class WiegandTagTrigger : public Trigger {}; +class WiegandTagTrigger final : public Trigger {}; -class WiegandRawTrigger : public Trigger {}; +class WiegandRawTrigger final : public Trigger {}; -class WiegandKeyTrigger : public Trigger {}; +class WiegandKeyTrigger final : public Trigger {}; -class Wiegand : public key_provider::KeyProvider, public Component { +class Wiegand final : public key_provider::KeyProvider, public Component { public: float get_setup_priority() const override { return setup_priority::HARDWARE; } void setup() override; diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index 1ad69b39925..e63faa18ab7 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,32 +6,32 @@ namespace esphome::wifi { -template class WiFiConnectedCondition : public Condition { +template class WiFiConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } }; -template class WiFiEnabledCondition : public Condition { +template class WiFiEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } }; -template class WiFiAPActiveCondition : public Condition { +template class WiFiAPActiveCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } }; -template class WiFiEnableAction : public Action { +template class WiFiEnableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->enable(); } }; -template class WiFiDisableAction : public Action { +template class WiFiDisableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->disable(); } }; -template class WiFiConfigureAction : public Action, public Component { +template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 9ff4cc54a09..af41465e710 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -10,9 +10,9 @@ namespace esphome::wifi_signal { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { #else -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent { #endif public: #ifdef USE_WIFI_CONNECT_STATE_LISTENERS diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c11d592cd13..1fda8024159 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -32,7 +32,7 @@ struct AllowedIP { }; /// Main Wireguard component class. -class Wireguard : public PollingComponent { +class Wireguard final : public PollingComponent { public: void setup() override; void loop() override; @@ -165,25 +165,26 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; void mask_key_to(char *buffer, size_t len, const char *key); /// Condition to check if remote peer is online. -template class WireguardPeerOnlineCondition : public Condition, public Parented { +template +class WireguardPeerOnlineCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } }; /// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition : public Condition, public Parented { +template class WireguardEnabledCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_enabled(); } }; /// Action to enable Wireguard component. -template class WireguardEnableAction : public Action, public Parented { +template class WireguardEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enable(); } }; /// Action to disable Wireguard component. -template class WireguardDisableAction : public Action, public Parented { +template class WireguardDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->disable(); } }; diff --git a/esphome/components/wl_134/wl_134.h b/esphome/components/wl_134/wl_134.h index 973e5a1e7c3..fad64bd8fff 100644 --- a/esphome/components/wl_134/wl_134.h +++ b/esphome/components/wl_134/wl_134.h @@ -8,7 +8,7 @@ namespace esphome::wl_134 { -class Wl134Component : public text_sensor::TextSensor, public Component, public uart::UARTDevice { +class Wl134Component final : public text_sensor::TextSensor, public Component, public uart::UARTDevice { public: enum Rfid134Error { RFID134_ERROR_NONE, From 2067da4ff5aa43a6aa6596265bc3d0a446027397 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:33:07 +1200 Subject: [PATCH 285/343] Mark configurable classes as final (11/21: microphone-ms8607) (#16962) --- .../components/micro_wake_word/automation.h | 12 +++++------ .../micro_wake_word/micro_wake_word.h | 4 ++-- esphome/components/microphone/automation.h | 14 ++++++------- .../components/microphone/microphone_source.h | 2 +- esphome/components/mics_4514/mics_4514.h | 2 +- esphome/components/midea/air_conditioner.h | 2 +- esphome/components/midea_ir/midea_ir.h | 2 +- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.h | 6 +++--- esphome/components/mitsubishi/mitsubishi.h | 2 +- esphome/components/mixer/speaker/automation.h | 2 +- .../components/mixer/speaker/mixer_speaker.h | 4 ++-- esphome/components/mlx90393/sensor_mlx90393.h | 2 +- esphome/components/mlx90614/mlx90614.h | 2 +- esphome/components/mmc5603/mmc5603.h | 2 +- esphome/components/mmc5983/mmc5983.h | 2 +- .../binary_sensor/modbus_binarysensor.h | 2 +- .../modbus_controller/modbus_controller.h | 2 +- .../modbus_controller/number/modbus_number.h | 2 +- .../modbus_controller/output/modbus_output.h | 4 ++-- .../modbus_controller/select/modbus_select.h | 2 +- .../modbus_controller/sensor/modbus_sensor.h | 2 +- .../modbus_controller/switch/modbus_switch.h | 2 +- .../text_sensor/modbus_textsensor.h | 2 +- .../components/modbus_server/modbus_server.h | 2 +- .../monochromatic_light_output.h | 2 +- esphome/components/mopeka_ble/mopeka_ble.h | 2 +- .../mopeka_pro_check/mopeka_pro_check.h | 2 +- .../mopeka_std_check/mopeka_std_check.h | 2 +- esphome/components/motion/motion_component.h | 6 +++--- esphome/components/mpl3115a2/mpl3115a2.h | 2 +- .../binary_sensor/mpr121_binary_sensor.h | 4 +++- esphome/components/mpr121/mpr121.h | 4 ++-- esphome/components/mpu6050/mpu6050.h | 2 +- esphome/components/mpu6886/mpu6886.h | 2 +- .../mqtt/mqtt_alarm_control_panel.h | 2 +- esphome/components/mqtt/mqtt_binary_sensor.h | 2 +- esphome/components/mqtt/mqtt_button.h | 2 +- esphome/components/mqtt/mqtt_client.h | 20 +++++++++---------- esphome/components/mqtt/mqtt_climate.h | 2 +- esphome/components/mqtt/mqtt_cover.h | 2 +- esphome/components/mqtt/mqtt_date.h | 2 +- esphome/components/mqtt/mqtt_datetime.h | 2 +- esphome/components/mqtt/mqtt_event.h | 2 +- esphome/components/mqtt/mqtt_fan.h | 2 +- esphome/components/mqtt/mqtt_light.h | 2 +- esphome/components/mqtt/mqtt_lock.h | 2 +- esphome/components/mqtt/mqtt_number.h | 2 +- esphome/components/mqtt/mqtt_select.h | 2 +- esphome/components/mqtt/mqtt_sensor.h | 2 +- esphome/components/mqtt/mqtt_switch.h | 2 +- esphome/components/mqtt/mqtt_text.h | 2 +- esphome/components/mqtt/mqtt_text_sensor.h | 2 +- esphome/components/mqtt/mqtt_time.h | 2 +- esphome/components/mqtt/mqtt_update.h | 2 +- esphome/components/mqtt/mqtt_valve.h | 2 +- .../sensor/mqtt_subscribe_sensor.h | 2 +- .../text_sensor/mqtt_subscribe_text_sensor.h | 2 +- esphome/components/ms5611/ms5611.h | 2 +- 59 files changed, 89 insertions(+), 87 deletions(-) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h index e3b35583fb1..59dfc624fad 100644 --- a/esphome/components/micro_wake_word/automation.h +++ b/esphome/components/micro_wake_word/automation.h @@ -7,22 +7,22 @@ namespace esphome::micro_wake_word { -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class EnableModelAction : public Action { +template class EnableModelAction final : public Action { public: explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->enable(); } @@ -31,7 +31,7 @@ template class EnableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class DisableModelAction : public Action { +template class DisableModelAction final : public Action { public: explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->disable(); } @@ -40,7 +40,7 @@ template class DisableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class ModelIsEnabledCondition : public Condition { +template class ModelIsEnabledCondition final : public Condition { public: explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index e4c590a4232..aebb5b25954 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -31,10 +31,10 @@ enum State { STOPPED, }; -class MicroWakeWord : public Component +class MicroWakeWord final : public Component #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index 1dfd91f903a..c28616a290a 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,34 +7,34 @@ namespace esphome::microphone { -template class CaptureAction : public Action, public Parented { +template class CaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopCaptureAction : public Action, public Parented { +template class StopCaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->stop(); } }; -template class MuteAction : public Action, public Parented { +template class MuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(true); } }; -template class UnmuteAction : public Action, public Parented { +template class UnmuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(false); } }; -class DataTrigger : public Trigger &> { +class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); } }; -template class IsCapturingCondition : public Condition, public Parented { +template class IsCapturingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } }; diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index c3c675e854b..7be3b8cdb59 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -13,7 +13,7 @@ namespace esphome::microphone { static const int32_t MAX_GAIN_FACTOR = 64; -class MicrophoneSource { +class MicrophoneSource final { /* * @brief Helper class that handles converting raw microphone data to a requested format. * Components requesting microphone audio should register a callback through this class instead of registering a diff --git a/esphome/components/mics_4514/mics_4514.h b/esphome/components/mics_4514/mics_4514.h index 4f8b970f067..d8c422808a6 100644 --- a/esphome/components/mics_4514/mics_4514.h +++ b/esphome/components/mics_4514/mics_4514.h @@ -7,7 +7,7 @@ namespace esphome::mics_4514 { -class MICS4514Component : public PollingComponent, public i2c::I2CDevice { +class MICS4514Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) SUB_SENSOR(methane) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 6ed5a82ff57..bea6c2eadb3 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,7 +21,7 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; -class AirConditioner : public ApplianceBase, public climate::Climate { +class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } diff --git a/esphome/components/midea_ir/midea_ir.h b/esphome/components/midea_ir/midea_ir.h index dd883172d4d..e89eaf01104 100644 --- a/esphome/components/midea_ir/midea_ir.h +++ b/esphome/components/midea_ir/midea_ir.h @@ -11,7 +11,7 @@ const uint8_t MIDEA_TEMPC_MAX = 30; // Celsius const uint8_t MIDEA_TEMPF_MIN = 62; // Fahrenheit const uint8_t MIDEA_TEMPF_MAX = 86; // Fahrenheit -class MideaIR : public climate_ir::ClimateIR { +class MideaIR final : public climate_ir::ClimateIR { public: MideaIR() : climate_ir::ClimateIR( diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index c99f69989a5..7bf2feb73c5 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,7 +35,7 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MipiDsi : public display::Display { +class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index dfa8a36e1a0..1480004833e 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -98,9 +98,9 @@ class MipiRgb : public display::Display { }; #ifdef USE_SPI -class MipiRgbSpi : public MipiRgb, - public spi::SPIDevice { +class MipiRgbSpi final : public MipiRgb, + public spi::SPIDevice { public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 769390ce3a8..7925b7ce44f 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -38,7 +38,7 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 0x28, }; -class MitsubishiClimate : public climate_ir::ClimateIR { +class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h index cdfda0c700f..ea51b6b8892 100644 --- a/esphome/components/mixer/speaker/automation.h +++ b/esphome/components/mixer/speaker/automation.h @@ -6,7 +6,7 @@ #ifdef USE_ESP32 namespace esphome::mixer_speaker { -template class DuckingApplyAction : public Action, public Parented { +template class DuckingApplyAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, decibel_reduction); TEMPLATABLE_VALUE(uint32_t, duration); void play(const Ts &...x) override { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f1ae919b50d..00e89d17826 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -44,7 +44,7 @@ namespace esphome::mixer_speaker { class MixerSpeaker; -class SourceSpeaker : public speaker::Speaker, public Component { +class SourceSpeaker final : public speaker::Speaker, public Component { public: void dump_config() override; void setup() override; @@ -118,7 +118,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { uint32_t stopping_start_ms_{0}; }; -class MixerSpeaker : public Component { +class MixerSpeaker final : public Component { public: void dump_config() override; void setup() override; diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index 28053216e22..e3b7ae5d93d 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -20,7 +20,7 @@ enum MLX90393Setting { MLX90393_LAST, }; -class MLX90393Cls : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { +class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 12081f20acc..882ee45186a 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -6,7 +6,7 @@ namespace esphome::mlx90614 { -class MLX90614Component : public PollingComponent, public i2c::I2CDevice { +class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index 0d8eb152a72..d291e6d2728 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -12,7 +12,7 @@ enum MMC5603Datarate { MMC5603_DATARATE_255_0_HZ, }; -class MMC5603Component : public PollingComponent, public i2c::I2CDevice { +class MMC5603Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5983/mmc5983.h b/esphome/components/mmc5983/mmc5983.h index 020d3b2e4c1..3ab9e86dcda 100644 --- a/esphome/components/mmc5983/mmc5983.h +++ b/esphome/components/mmc5983/mmc5983.h @@ -6,7 +6,7 @@ namespace esphome::mmc5983 { -class MMC5983Component : public PollingComponent, public i2c::I2CDevice { +class MMC5983Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void setup() override; diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 98c6840e15f..3f7c6b4dd63 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, public SensorItem { +class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 4f674b2675e..501fadbcf1b 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { +class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index dd8f418bfc1..ce640991703 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { using value_to_data_t = std::function(float); -class ModbusNumber : public number::Number, public Component, public SensorItem { +class ModbusNumber final : public number::Number, public Component, public SensorItem { public: ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index c5323e3bf30..d904e58bd72 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusFloatOutput : public output::FloatOutput, public Component, public SensorItem { +class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { this->register_type = ModbusRegisterType::HOLDING; @@ -41,7 +41,7 @@ class ModbusFloatOutput : public output::FloatOutput, public Component, public S bool use_write_multiple_{false}; }; -class ModbusBinaryOutput : public output::BinaryOutput, public Component, public SensorItem { +class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = ModbusRegisterType::COIL; diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index a736abd0dbc..fb9283305c8 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -9,7 +9,7 @@ namespace esphome::modbus_controller { -class ModbusSelect : public Component, public select::Select, public SensorItem { +class ModbusSelect final : public Component, public select::Select, public SensorItem { public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 2e6967b07cd..ea4f560b9c1 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSensor : public Component, public sensor::Sensor, public SensorItem { +class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 541a23706d1..d6e991582dc 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSwitch : public Component, public switch_::Switch, public SensorItem { +class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index a99fea58609..e9130c98d46 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; -class ModbusTextSensor : public Component, public text_sensor::TextSensor, public SensorItem { +class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f68d1c4a30f..a5d193cb411 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -95,7 +95,7 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusServerDevice { +class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; diff --git a/esphome/components/monochromatic/monochromatic_light_output.h b/esphome/components/monochromatic/monochromatic_light_output.h index 458140ef09c..eb81a10ee4b 100644 --- a/esphome/components/monochromatic/monochromatic_light_output.h +++ b/esphome/components/monochromatic/monochromatic_light_output.h @@ -6,7 +6,7 @@ namespace esphome::monochromatic { -class MonochromaticLightOutput : public light::LightOutput { +class MonochromaticLightOutput final : public light::LightOutput { public: void set_output(output::FloatOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index cc91ef17d67..e6fae23aee8 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -9,7 +9,7 @@ namespace esphome::mopeka_ble { -class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index bfdfe80c486..40fb3383505 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -27,7 +27,7 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index a38abeabf03..2f1681f6ea1 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -42,7 +42,7 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 00310c16fe3..b0a074a17c2 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -85,7 +85,7 @@ class MotionComponent : public PollingComponent { // --- Actions --- -template class CalibrateLevelAction : public Action { +template class CalibrateLevelAction final : public Action { public: explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -110,7 +110,7 @@ template class CalibrateLevelAction : public Action { bool save_{false}; }; -template class CalibrateHeadingAction : public Action { +template class CalibrateHeadingAction final : public Action { public: explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -135,7 +135,7 @@ template class CalibrateHeadingAction : public Action { bool save_{false}; }; -template class ClearCalibrationAction : public Action { +template class ClearCalibrationAction final : public Action { public: explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index d78c9d571c8..a6163673cbf 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -80,7 +80,7 @@ enum { MPL3115A2_CTRL_REG1_OS128 = 0x38, }; -class MPL3115A2Component : public PollingComponent, public i2c::I2CDevice { +class MPL3115A2Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_altitude(sensor::Sensor *altitude) { altitude_ = altitude; } diff --git a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h index 5fa10bf5980..c0a4a36f1fe 100644 --- a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h +++ b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h @@ -6,7 +6,9 @@ namespace esphome::mpr121 { -class MPR121BinarySensor : public binary_sensor::BinarySensor, public MPR121Channel, public Parented { +class MPR121BinarySensor final : public binary_sensor::BinarySensor, + public MPR121Channel, + public Parented { public: void set_channel(uint8_t channel) { this->channel_ = channel; } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 54b5c8abf46..64c4b291b3d 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -57,7 +57,7 @@ class MPR121Channel { virtual void process(uint16_t data) = 0; }; -class MPR121Component : public Component, public i2c::I2CDevice { +class MPR121Component final : public Component, public i2c::I2CDevice { public: void register_channel(MPR121Channel *channel) { this->channels_.push_back(channel); } void set_touch_debounce(uint8_t debounce); @@ -102,7 +102,7 @@ class MPR121Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin : public GPIOPin { +class MPR121GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index bac07cb4a5e..4410bf01645 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -6,7 +6,7 @@ namespace esphome::mpu6050 { -class MPU6050Component : public PollingComponent, public i2c::I2CDevice { +class MPU6050Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index a23858a7b7c..b795d5f690f 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -6,7 +6,7 @@ namespace esphome::mpu6886 { -class MPU6886Component : public PollingComponent, public i2c::I2CDevice { +class MPU6886Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index 89a0ff1be82..b2da7ed6a25 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { +class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); diff --git a/esphome/components/mqtt/mqtt_binary_sensor.h b/esphome/components/mqtt/mqtt_binary_sensor.h index 5917a9966c3..75c224c65a6 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.h +++ b/esphome/components/mqtt/mqtt_binary_sensor.h @@ -9,7 +9,7 @@ namespace esphome::mqtt { -class MQTTBinarySensorComponent : public mqtt::MQTTComponent { +class MQTTBinarySensorComponent final : public mqtt::MQTTComponent { public: /** Construct a MQTTBinarySensorComponent. * diff --git a/esphome/components/mqtt/mqtt_button.h b/esphome/components/mqtt/mqtt_button.h index a2db64d39d8..7e2c77e29b9 100644 --- a/esphome/components/mqtt/mqtt_button.h +++ b/esphome/components/mqtt/mqtt_button.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTButtonComponent : public mqtt::MQTTComponent { +class MQTTButtonComponent final : public mqtt::MQTTComponent { public: explicit MQTTButtonComponent(button::Button *button); diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 14473f737a0..f741be561c8 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -99,7 +99,7 @@ enum MQTTClientState { class MQTTComponent; -class MQTTClientComponent : public Component { +class MQTTClientComponent final : public Component { public: MQTTClientComponent(); @@ -340,7 +340,7 @@ class MQTTClientComponent : public Component { extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger : public Trigger, public Component { +class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); @@ -356,7 +356,7 @@ class MQTTMessageTrigger : public Trigger, public Component { optional payload_; }; -class MQTTJsonMessageTrigger : public Trigger { +class MQTTJsonMessageTrigger final : public Trigger { public: explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { global_mqtt_client->subscribe_json( @@ -364,21 +364,21 @@ class MQTTJsonMessageTrigger : public Trigger { } }; -class MQTTConnectTrigger : public Trigger { +class MQTTConnectTrigger final : public Trigger { public: explicit MQTTConnectTrigger(MQTTClientComponent *client) { client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); } }; -class MQTTDisconnectTrigger : public Trigger { +class MQTTDisconnectTrigger final : public Trigger { public: explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); } }; -template class MQTTPublishAction : public Action { +template class MQTTPublishAction final : public Action { public: MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -395,7 +395,7 @@ template class MQTTPublishAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTPublishJsonAction : public Action { +template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -417,7 +417,7 @@ template class MQTTPublishJsonAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTConnectedCondition : public Condition { +template class MQTTConnectedCondition final : public Condition { public: MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->is_connected(); } @@ -426,7 +426,7 @@ template class MQTTConnectedCondition : public Condition MQTTClientComponent *parent_; }; -template class MQTTEnableAction : public Action { +template class MQTTEnableAction final : public Action { public: MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -436,7 +436,7 @@ template class MQTTEnableAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTDisableAction : public Action { +template class MQTTDisableAction final : public Action { public: MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_climate.h b/esphome/components/mqtt/mqtt_climate.h index f0715929d4b..b862db85aea 100644 --- a/esphome/components/mqtt/mqtt_climate.h +++ b/esphome/components/mqtt/mqtt_climate.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTClimateComponent : public mqtt::MQTTComponent { +class MQTTClimateComponent final : public mqtt::MQTTComponent { public: MQTTClimateComponent(climate::Climate *device); void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; diff --git a/esphome/components/mqtt/mqtt_cover.h b/esphome/components/mqtt/mqtt_cover.h index f801af5d128..3b07733993d 100644 --- a/esphome/components/mqtt/mqtt_cover.h +++ b/esphome/components/mqtt/mqtt_cover.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTCoverComponent : public mqtt::MQTTComponent { +class MQTTCoverComponent final : public mqtt::MQTTComponent { public: explicit MQTTCoverComponent(cover::Cover *cover); diff --git a/esphome/components/mqtt/mqtt_date.h b/esphome/components/mqtt/mqtt_date.h index 4a626becb2b..1c244228561 100644 --- a/esphome/components/mqtt/mqtt_date.h +++ b/esphome/components/mqtt/mqtt_date.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateComponent : public mqtt::MQTTComponent { +class MQTTDateComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateComponent instance with the provided friendly_name and date * diff --git a/esphome/components/mqtt/mqtt_datetime.h b/esphome/components/mqtt/mqtt_datetime.h index d02d6f579c0..09af806fe34 100644 --- a/esphome/components/mqtt/mqtt_datetime.h +++ b/esphome/components/mqtt/mqtt_datetime.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateTimeComponent : public mqtt::MQTTComponent { +class MQTTDateTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_event.h b/esphome/components/mqtt/mqtt_event.h index e6d5b6f2783..424de3f6039 100644 --- a/esphome/components/mqtt/mqtt_event.h +++ b/esphome/components/mqtt/mqtt_event.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTEventComponent : public mqtt::MQTTComponent { +class MQTTEventComponent final : public mqtt::MQTTComponent { public: explicit MQTTEventComponent(event::Event *event); diff --git a/esphome/components/mqtt/mqtt_fan.h b/esphome/components/mqtt/mqtt_fan.h index 43ef67e733b..ff984bb77d2 100644 --- a/esphome/components/mqtt/mqtt_fan.h +++ b/esphome/components/mqtt/mqtt_fan.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTFanComponent : public mqtt::MQTTComponent { +class MQTTFanComponent final : public mqtt::MQTTComponent { public: explicit MQTTFanComponent(fan::Fan *state); diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 41981655eff..2ca8d70dd40 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { +class MQTTJSONLightComponent final : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); diff --git a/esphome/components/mqtt/mqtt_lock.h b/esphome/components/mqtt/mqtt_lock.h index 666882c73df..7f36a517893 100644 --- a/esphome/components/mqtt/mqtt_lock.h +++ b/esphome/components/mqtt/mqtt_lock.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTLockComponent : public mqtt::MQTTComponent { +class MQTTLockComponent final : public mqtt::MQTTComponent { public: explicit MQTTLockComponent(lock::Lock *a_lock); diff --git a/esphome/components/mqtt/mqtt_number.h b/esphome/components/mqtt/mqtt_number.h index 021a539988e..5e215446914 100644 --- a/esphome/components/mqtt/mqtt_number.h +++ b/esphome/components/mqtt/mqtt_number.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTNumberComponent : public mqtt::MQTTComponent { +class MQTTNumberComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTNumberComponent instance with the provided friendly_name and number * diff --git a/esphome/components/mqtt/mqtt_select.h b/esphome/components/mqtt/mqtt_select.h index aaf174ff72e..46140ad456b 100644 --- a/esphome/components/mqtt/mqtt_select.h +++ b/esphome/components/mqtt/mqtt_select.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSelectComponent : public mqtt::MQTTComponent { +class MQTTSelectComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSelectComponent instance with the provided friendly_name and select * diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index e8202aa8e2e..1d5ee8095c6 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSensorComponent : public mqtt::MQTTComponent { +class MQTTSensorComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSensorComponent instance with the provided friendly_name and sensor * diff --git a/esphome/components/mqtt/mqtt_switch.h b/esphome/components/mqtt/mqtt_switch.h index 5f6cb841fd0..f35784ed5c3 100644 --- a/esphome/components/mqtt/mqtt_switch.h +++ b/esphome/components/mqtt/mqtt_switch.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSwitchComponent : public mqtt::MQTTComponent { +class MQTTSwitchComponent final : public mqtt::MQTTComponent { public: explicit MQTTSwitchComponent(switch_::Switch *a_switch); diff --git a/esphome/components/mqtt/mqtt_text.h b/esphome/components/mqtt/mqtt_text.h index 8ae0b9e29a8..d42eefc690b 100644 --- a/esphome/components/mqtt/mqtt_text.h +++ b/esphome/components/mqtt/mqtt_text.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextComponent : public mqtt::MQTTComponent { +class MQTTTextComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTextComponent instance with the provided friendly_name and text * diff --git a/esphome/components/mqtt/mqtt_text_sensor.h b/esphome/components/mqtt/mqtt_text_sensor.h index d8f9315c1e7..1fe9651fa1b 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.h +++ b/esphome/components/mqtt/mqtt_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextSensor : public mqtt::MQTTComponent { +class MQTTTextSensor final : public mqtt::MQTTComponent { public: explicit MQTTTextSensor(text_sensor::TextSensor *sensor); diff --git a/esphome/components/mqtt/mqtt_time.h b/esphome/components/mqtt/mqtt_time.h index cf5780da2d8..3e60176e901 100644 --- a/esphome/components/mqtt/mqtt_time.h +++ b/esphome/components/mqtt/mqtt_time.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTimeComponent : public mqtt::MQTTComponent { +class MQTTTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_update.h b/esphome/components/mqtt/mqtt_update.h index ec1adb1fcd4..04b0b09da18 100644 --- a/esphome/components/mqtt/mqtt_update.h +++ b/esphome/components/mqtt/mqtt_update.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTUpdateComponent : public mqtt::MQTTComponent { +class MQTTUpdateComponent final : public mqtt::MQTTComponent { public: explicit MQTTUpdateComponent(update::UpdateEntity *update); diff --git a/esphome/components/mqtt/mqtt_valve.h b/esphome/components/mqtt/mqtt_valve.h index d3b724a8baa..dd2cca514ab 100644 --- a/esphome/components/mqtt/mqtt_valve.h +++ b/esphome/components/mqtt/mqtt_valve.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTValveComponent : public mqtt::MQTTComponent { +class MQTTValveComponent final : public mqtt::MQTTComponent { public: explicit MQTTValveComponent(valve::Valve *valve); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 229c0586ab7..739e8456ee8 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeSensor : public sensor::Sensor, public Component { +class MQTTSubscribeSensor final : public sensor::Sensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index f218bf2a8a3..8641825fca9 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeTextSensor : public text_sensor::TextSensor, public Component { +class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/ms5611/ms5611.h b/esphome/components/ms5611/ms5611.h index c6ad5b231ae..535acdd3570 100644 --- a/esphome/components/ms5611/ms5611.h +++ b/esphome/components/ms5611/ms5611.h @@ -6,7 +6,7 @@ namespace esphome::ms5611 { -class MS5611Component : public PollingComponent, public i2c::I2CDevice { +class MS5611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; From 66ab807596555b5516561abe774995ff25b3a119 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 6 Jul 2026 09:43:31 -0700 Subject: [PATCH 286/343] [modbus] API naming (#17378) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/helpers.py | 2 +- esphome/components/modbus/modbus.cpp | 8 ++--- esphome/components/modbus/modbus.h | 23 ++++++------ .../components/modbus/modbus_definitions.h | 7 +++- esphome/components/modbus/modbus_helpers.h | 4 +-- .../components/modbus_controller/__init__.py | 2 +- .../modbus_server/modbus_server.cpp | 9 +++-- .../components/modbus_server/modbus_server.h | 7 ++-- .../modbus_server/modbus_server_test.cpp | 36 +++++++++---------- 9 files changed, 51 insertions(+), 47 deletions(-) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index 6f97f1e6051..9d7dc71547f 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -29,7 +29,7 @@ MODBUS_WRITE_REGISTER_TYPE = { MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.READ, + "read": ModbusRegisterType.INPUT_REGISTER, } SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 488bcf14592..eefab7967f6 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -360,7 +360,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func return; } - ServerResponseStatus status; + ResponseStatus status; uint8_t response_buffer[modbus::MAX_RAW_SIZE]; const uint8_t *response_data = response_buffer; uint16_t response_len = 0; @@ -381,9 +381,9 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } RegisterValues registers; if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { - status = device->on_modbus_read_holding_registers(start_address, number_of_registers, registers); + status = device->on_read_holding_registers(start_address, number_of_registers, registers); } else { - status = device->on_modbus_read_input_registers(start_address, number_of_registers, registers); + status = device->on_read_input_registers(start_address, number_of_registers, registers); } // A handler that returns an exception leaves registers partially filled, so check the exception @@ -436,7 +436,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func for (uint16_t i = 0; i < number_of_registers; i++) { registers.push_back(helpers::get_data(data, values_offset + i * 2)); } - status = device->on_modbus_write_registers(start_address, registers); + status = device->on_write_registers(start_address, registers); response_data = data; // echo the request header per Modbus 6.6, 6.12 response_len = 4; break; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index b0f2aed9f82..d995c441ade 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -201,8 +201,9 @@ class ModbusClientDevice { using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", "2026.6.0") = ModbusClientDevice; -// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return. -using ServerResponseStatus = std::optional; +// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; +// (future) client response callbacks receive it. Named without a side prefix so both directions share it. +using ResponseStatus = std::optional; // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -219,19 +220,19 @@ class ModbusServerDevice { ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; void set_address(uint8_t address) { this->address_ = address; } uint8_t get_address() const { return this->address_; } - virtual ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { + virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { return ModbusExceptionCode::ILLEGAL_FUNCTION; }; - virtual ServerResponseStatus on_modbus_read_input_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { - return this->on_modbus_read_registers(start_address, number_of_registers, registers); + virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); }; - virtual ServerResponseStatus on_modbus_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, - RegisterValues ®isters) { - return this->on_modbus_read_registers(start_address, number_of_registers, registers); + virtual ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); }; - virtual ServerResponseStatus on_modbus_write_registers(uint16_t start_address, const RegisterValues ®isters) { + virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ModbusExceptionCode::ILLEGAL_FUNCTION; }; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 1c03498f1da..a5bcc1e3fc1 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus { @@ -48,7 +49,11 @@ enum class ModbusRegisterType : uint8_t { COIL = 0x01, DISCRETE_INPUT = 0x02, HOLDING = 0x03, - READ = 0x04, + // Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro. + INPUT_REGISTER = 0x04, + // Remove before 2027.2.0 + READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = + INPUT_REGISTER, }; // 7 MODBUS Exception Responses: diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index b7b9020945a..fef0f915eab 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -90,7 +90,7 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t return ModbusFunctionCode::READ_DISCRETE_INPUTS; case ModbusRegisterType::HOLDING: return ModbusFunctionCode::READ_HOLDING_REGISTERS; - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: return ModbusFunctionCode::INVALID; @@ -104,7 +104,7 @@ inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_ case ModbusRegisterType::HOLDING: return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; // These register types can't be written (per spec) - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: case ModbusRegisterType::DISCRETE_INPUT: default: return ModbusFunctionCode::INVALID; diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index cdbba54c1f9..527e9b047fb 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -220,7 +220,7 @@ def function_code_to_register(function_code): "read_coils": ModbusRegisterType.COIL, "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, "read_holding_registers": ModbusRegisterType.HOLDING, - "read_input_registers": ModbusRegisterType.READ, + "read_input_registers": ModbusRegisterType.INPUT_REGISTER, "write_single_coil": ModbusRegisterType.COIL, "write_single_register": ModbusRegisterType.HOLDING, "write_multiple_coils": ModbusRegisterType.COIL, diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 44b1b160a5d..1f787a0b612 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -27,9 +27,8 @@ ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const return nullptr; } -modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t start_address, - uint16_t number_of_registers, - modbus::RegisterValues ®isters) { +modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { ESP_LOGV(TAG, "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); @@ -101,8 +100,8 @@ modbus::ServerResponseStatus ModbusServer::on_modbus_read_registers(uint16_t sta return {}; } -modbus::ServerResponseStatus ModbusServer::on_modbus_write_registers(uint16_t start_address, - const modbus::RegisterValues ®isters) { +modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { // registers holds the values to write in host byte order; its size is the register count. ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index a5d193cb411..4fddd9854d6 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -102,11 +102,10 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - modbus::ServerResponseStatus on_modbus_read_registers(uint16_t start_address, uint16_t number_of_registers, - modbus::RegisterValues ®isters) final; + modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - modbus::ServerResponseStatus on_modbus_write_registers(uint16_t start_address, - const modbus::RegisterValues ®isters) final; + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 419bb9cf25d..d95bb473c92 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -29,7 +29,7 @@ TEST(ModbusServerWrite, SingleWordSucceeds) { }; server.add_server_register(®); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); EXPECT_FALSE(status.has_value()); // nullopt == success EXPECT_EQ(written, 0x1234); } @@ -45,7 +45,7 @@ TEST(ModbusServerWrite, DwordSucceeds) { }; server.add_server_register(®); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234, 0x5678})); EXPECT_FALSE(status.has_value()); EXPECT_EQ(written, 0x12345678); } @@ -70,7 +70,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { server.add_server_register(&dword_reg); // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222})); + auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -84,7 +84,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set server.add_server_register(&read_only); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234})); + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -93,7 +93,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { // An address with no registered register yields ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; - auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234})); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -114,14 +114,14 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { server.add_server_register(&first); server.add_server_register(&second); - auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure } -// --- on_modbus_read_registers -------------------------------------------------- +// --- on_read_registers -------------------------------------------------- TEST(ModbusServerRead, SingleWordSucceeds) { ModbusServer server; @@ -130,7 +130,7 @@ TEST(ModbusServerRead, SingleWordSucceeds) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); + auto status = server.on_read_registers(0x0000, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x1234); @@ -143,7 +143,7 @@ TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 2, out); + auto status = server.on_read_registers(0x0000, 2, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 2u); EXPECT_EQ(out[0], 0x1234); @@ -165,7 +165,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD + auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -184,7 +184,7 @@ TEST(ModbusServerRead, ClippedTailRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -200,7 +200,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0000, 1, out); + auto status = server.on_read_registers(0x0000, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -213,7 +213,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0005, 2, out); + auto status = server.on_read_registers(0x0005, 2, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 2u); EXPECT_EQ(out[0], 0xABCD); @@ -224,7 +224,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; RegisterValues out; - auto status = server.on_modbus_read_registers(0x0005, 1, out); + auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); @@ -241,7 +241,7 @@ TEST(ModbusServerRead, PartialReadHighWord) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0010, 1, out); + auto status = server.on_read_registers(0x0010, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x1234); @@ -256,7 +256,7 @@ TEST(ModbusServerRead, PartialReadLowWordFromInterior) { server.add_server_register(®); RegisterValues out; - auto status = server.on_modbus_read_registers(0x0011, 1, out); + auto status = server.on_read_registers(0x0011, 1, out); EXPECT_FALSE(status.has_value()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0], 0x5678); @@ -272,12 +272,12 @@ TEST(ModbusServerRead, PartialReadReversedType) { server.add_server_register(®); RegisterValues first; - ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value()); + ASSERT_FALSE(server.on_read_registers(0x0010, 1, first).has_value()); ASSERT_EQ(first.size(), 1u); EXPECT_EQ(first[0], 0x5678); RegisterValues second; - ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value()); + ASSERT_FALSE(server.on_read_registers(0x0011, 1, second).has_value()); ASSERT_EQ(second.size(), 1u); EXPECT_EQ(second[0], 0x1234); } From b79db760999ae6bcaeacf56bfc6d32dc9205e493 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Mon, 6 Jul 2026 20:54:20 +0200 Subject: [PATCH 287/343] [core] helpers.h - Implement pop_back method for vector (#17390) --- esphome/core/helpers.h | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 07bcb7a74fa..a2120196280 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -683,6 +683,15 @@ template class FixedVector { T &back() { return data_[size_ - 1]; } const T &back() const { return data_[size_ - 1]; } + /// Remove the last element in place (no reallocation, keeps capacity) + /// Caller must ensure vector is not empty (size() > 0) + void pop_back() { + if constexpr (!std::is_trivially_destructible::value) { + data_[size_ - 1].~T(); + } + size_--; + } + size_t size() const { return size_; } bool empty() const { return size_ == 0; } size_t capacity() const { return capacity_; } From e64a79f43137627c32fc60b6f0ef34b27f44ffa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:29 -0500 Subject: [PATCH 288/343] Bump setuptools from 82.0.1 to 83.0.0 (#17426) Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e9595785539..f38633b4aeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"] +requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] From 104c2f86f6de5eefd49e281ae1926feda700e829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:43 -0500 Subject: [PATCH 289/343] Bump astral-sh/setup-uv from 8.2.0 to 8.3.0 in /.github/actions/restore-python (#17427) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 1364e956026..8ef0bca2ec3 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 27d4b63a8a36e09b404505a18e5a1e176c8aeb44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:55 -0500 Subject: [PATCH 290/343] Bump astral-sh/setup-uv from 8.2.0 to 8.3.0 (#17428) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 4c0c330a191..721585a44dc 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34f8ed4878d..11e29db94a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 0501d6d364f..2efaec4e948 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 51fa25856d68300862fc5668e1705536d3b03b2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 14:42:14 -0500 Subject: [PATCH 291/343] [bluetooth_proxy] Take over stale advertisement subscription instead of rejecting the new subscriber (#17423) --- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ca30aab9437..37ebcad8b48 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -379,9 +379,17 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn } void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); - return; + if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { + // A previous subscriber still holds the slot. This is almost always a stale + // connection from a client that dropped without a clean disconnect and has + // not yet hit the keepalive timeout; rejecting the new subscriber would + // silently starve it of advertisements until it reconnects, so the newest + // subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; this->parent_->recalculate_advertisement_parser_types(); From 90403576c407a1610c71261b93be08b2eb3d2cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 14:42:30 -0500 Subject: [PATCH 292/343] [wifi] Accept boolean-like strings for fast_connect again (#17414) --- esphome/components/wifi/__init__.py | 8 +++++--- .../validate-fast-connect-substitution.esp8266-ard.yaml | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 111f4cfc849..abce1fd5c05 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation, preferences from esphome.automation import Condition @@ -444,9 +445,10 @@ FAST_CONNECT_SCHEMA = cv.Schema( ) -def _fast_connect_schema(value): - """Accept the historic plain boolean or a dict with enabled/storage keys.""" - if isinstance(value, bool): +def _fast_connect_schema(value: Any) -> ConfigType: + """Accept the historic plain boolean (including boolean-like strings from + substitutions) or a dict with enabled/storage keys.""" + if not isinstance(value, dict): value = {CONF_ENABLED: value} return FAST_CONNECT_SCHEMA(value) diff --git a/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml new file mode 100644 index 00000000000..f9fab8261a8 --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml @@ -0,0 +1,9 @@ +# fast_connect passed through a substitution arrives as a string ("false"), +# which must be accepted like the historic plain boolean form. +substitutions: + fast_connect_value: "false" + +wifi: + ssid: MySSID + password: password1 + fast_connect: ${fast_connect_value} From 39ad583b39f8f14fb3abf5063659488f80abf6ad Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 21:48:24 +0200 Subject: [PATCH 293/343] [nrf52] allow to build for non nrf52840 boards (#17373) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 23 ++++++++++--------- .../components/nrf52/test.nrf52-microbit.yaml | 1 + .../build_components_base.nrf52-microbit.yaml | 16 +++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 tests/components/nrf52/test.nrf52-microbit.yaml create mode 100644 tests/test_build_components/build_components_base.nrf52-microbit.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 661fc0758e4..692b2637b20 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -233,7 +233,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): _dfu_schema, - cv.Optional(CONF_DCDC, default=True): cv.boolean, + cv.Optional(CONF_DCDC): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { cv.Required(CONF_VOLTAGE): cv.All( @@ -367,16 +367,17 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if framework_ver < cv.Version(2, 9, 2): - zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) - else: - zephyr_add_overlay( - f""" - ®1 {{ - regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; - }}; - """ - ) + if CONF_DCDC in config: + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + else: + zephyr_add_overlay( + f""" + ®1 {{ + regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; + }}; + """ + ) if reg0_config := config.get(CONF_REG0): value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) diff --git a/tests/components/nrf52/test.nrf52-microbit.yaml b/tests/components/nrf52/test.nrf52-microbit.yaml new file mode 100644 index 00000000000..d27f9ff6995 --- /dev/null +++ b/tests/components/nrf52/test.nrf52-microbit.yaml @@ -0,0 +1 @@ +nrf52: diff --git a/tests/test_build_components/build_components_base.nrf52-microbit.yaml b/tests/test_build_components/build_components_base.nrf52-microbit.yaml new file mode 100644 index 00000000000..37728b4b643 --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-microbit.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: bbc_microbit + +logger: + level: VERY_VERBOSE + hardware_uart: UART0 + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 468b32b9865989a349eab2712dc5bb1dda2ea941 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 6 Jul 2026 21:50:57 +0200 Subject: [PATCH 294/343] [nrf52] add better error message for OTA error (#17407) --- esphome/components/nrf52/ota.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index 5d608acbacd..cafeda64785 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -5,7 +5,7 @@ import logging from pathlib import Path from bleak import BleakScanner -from bleak.exc import BleakDeviceNotFoundError +from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient from smpclient.generics import error, success @@ -98,6 +98,12 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: await smp_client.connect() except BleakDeviceNotFoundError as exc: raise EsphomeError(f"Device {device} not found") from exc + except BleakDBusError as exc: + if "NotPermitted" in exc.dbus_error: + raise EsphomeError( + f"Cannot connect to {device}: Make sure the device is paired." + ) from exc + raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: raise EsphomeError(f"Connection error with {device}") from exc From 9caf4317403bb7bfeae4ab31801532f4895e98a5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:55:53 +1200 Subject: [PATCH 295/343] [tests] Document dict-style packages requirement for batch grouping (#17420) --- AGENTS.md | 9 +++++---- tests/test_build_components/common/README.md | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a01626ee42..75a9cdb2bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,13 +427,14 @@ This document provides essential context for AI models interacting with this pro When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes. - * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`. + + All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`): ```yaml - # test.esp32-idf.yaml — use packages for buses + # test.esp32-idf.yaml — everything included via named packages packages: uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - - <<: !include common.yaml + my_component: !include common.yaml ``` ```yaml # common.yaml — component config only, NO bus definitions diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 5e925d00674..a3c6f476e09 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -45,14 +45,13 @@ common/ ## How It Works ### Component Test Structure -Each component test includes the common bus config: +Each component test includes the common bus config and its own `common.yaml` through dict-style `packages:`. Always use packages for every include — the grouping scripts only understand dict-style packages, so list-style packages or top-level `<<:` merge keys prevent correct batch grouping. Key the bus package by the bus name and the component's `common.yaml` by the component name: ```yaml # tests/components/bh1750/test.esp32-idf.yaml packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bh1750: !include common.yaml ``` The common config provides: From cdd334284ecb99fb127c79d45e10104f99ca49ae Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:20:24 +1200 Subject: [PATCH 296/343] [rp2] Rename rp2040 platform to rp2 (#17145) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CODEOWNERS | 2 +- esphome/__main__.py | 10 +- esphome/components/__init__.py | 6 + esphome/components/adc/__init__.py | 8 +- esphome/components/adc/adc_sensor.h | 8 +- ...c_sensor_rp2040.cpp => adc_sensor_rp2.cpp} | 6 +- esphome/components/adc/sensor.py | 2 +- esphome/components/api/__init__.py | 6 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_connection.h | 8 +- esphome/components/async_tcp/__init__.py | 6 +- esphome/components/async_tcp/async_tcp.h | 2 +- .../components/async_tcp/async_tcp_socket.cpp | 2 +- .../components/async_tcp/async_tcp_socket.h | 2 +- esphome/components/captive_portal/__init__.py | 6 +- esphome/components/debug/__init__.py | 2 +- .../debug/{debug_rp2040.cpp => debug_rp2.cpp} | 10 +- esphome/components/esp8266/helpers.cpp | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/ethernet/__init__.py | 28 +- .../components/ethernet/ethernet_component.h | 10 +- ..._rp2040.cpp => ethernet_component_rp2.cpp} | 8 +- .../factory_reset/factory_reset.cpp | 4 +- .../components/factory_reset/factory_reset.h | 4 +- .../components/gpio/binary_sensor/__init__.py | 2 +- .../components/hmac_sha256/hmac_sha256.cpp | 2 +- esphome/components/hmac_sha256/hmac_sha256.h | 2 +- esphome/components/http_request/__init__.py | 12 +- .../http_request/http_request_arduino.cpp | 2 +- .../http_request/http_request_arduino.h | 2 +- .../components/http_request/ota/__init__.py | 2 +- esphome/components/i2c/__init__.py | 16 +- esphome/components/i2c/i2c_bus_arduino.cpp | 8 +- ...p2040.cpp => internal_temperature_rp2.cpp} | 6 +- .../components/internal_temperature/sensor.py | 6 +- esphome/components/logger/__init__.py | 18 +- esphome/components/logger/logger.cpp | 2 +- esphome/components/logger/logger.h | 16 +- .../{logger_rp2040.cpp => logger_rp2.cpp} | 12 +- .../logger/{logger_rp2040.h => logger_rp2.h} | 2 +- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- esphome/components/md5/md5.cpp | 8 +- esphome/components/md5/md5.h | 2 +- esphome/components/mdns/__init__.py | 12 +- esphome/components/mdns/mdns_component.cpp | 8 +- esphome/components/mdns/mdns_component.h | 4 +- .../mdns/{mdns_rp2040.cpp => mdns_rp2.cpp} | 6 +- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/components/network/__init__.py | 6 +- esphome/components/nextion/__init__.py | 2 +- esphome/components/online_image/__init__.py | 2 +- esphome/components/ota/__init__.py | 4 +- ...rp2040.cpp => ota_backend_arduino_rp2.cpp} | 26 +- ...ino_rp2040.h => ota_backend_arduino_rp2.h} | 8 +- esphome/components/ota/ota_backend_factory.h | 4 +- .../components/remote_receiver/__init__.py | 4 +- .../remote_receiver/remote_receiver.cpp | 2 +- .../remote_receiver/remote_receiver.h | 6 +- .../components/remote_transmitter/__init__.py | 2 +- .../remote_transmitter/remote_transmitter.cpp | 2 +- .../remote_transmitter/remote_transmitter.h | 2 +- .../components/{rp2040 => rp2}/__init__.py | 59 +- .../components/{rp2040 => rp2}/boards.jinja2 | 13 +- esphome/components/{rp2040 => rp2}/boards.py | 13 +- .../{rp2040 => rp2}/build_pio.py.script | 0 esphome/components/{rp2040 => rp2}/const.py | 4 +- esphome/components/rp2/core.cpp | 6 + esphome/components/{rp2040 => rp2}/core.h | 6 +- .../{rp2040 => rp2}/crash_handler.cpp | 14 +- .../{rp2040 => rp2}/crash_handler.h | 12 +- .../{rp2040 => rp2}/generate_boards.py | 2 +- esphome/components/{rp2040 => rp2}/gpio.cpp | 28 +- esphome/components/{rp2040 => rp2}/gpio.h | 10 +- esphome/components/{rp2040 => rp2}/gpio.py | 26 +- esphome/components/{rp2040 => rp2}/hal.cpp | 22 +- esphome/components/{rp2040 => rp2}/hal.h | 6 +- .../components/{rp2040 => rp2}/helpers.cpp | 4 +- .../inject_lwip_include.py.script | 0 .../{rp2040 => rp2}/lwipopts.h.jinja | 0 .../{rp2040 => rp2}/post_build.py.script | 0 esphome/components/rp2/preference_backend.h | 27 + .../{rp2040 => rp2}/preferences.cpp | 28 +- .../components/{rp2040 => rp2}/preferences.h | 16 +- .../{rp2040 => rp2}/printf_stubs.cpp | 6 +- esphome/components/rp2040/core.cpp | 6 - .../components/rp2040/preference_backend.h | 27 - esphome/components/rp2040_ble/__init__.py | 2 +- .../rp2040_pio_led_strip/led_strip.cpp | 2 +- .../rp2040_pio_led_strip/led_strip.h | 4 +- .../components/rp2040_pio_led_strip/light.py | 10 +- esphome/components/rp2040_pwm/output.py | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.cpp | 2 +- esphome/components/rp2040_pwm/rp2040_pwm.h | 4 +- .../{rp2040_pio => rp2_pio}/__init__.py | 2 +- esphome/components/sha256/sha256.cpp | 4 +- esphome/components/sha256/sha256.h | 6 +- esphome/components/sntp/time.py | 4 +- esphome/components/socket/__init__.py | 2 +- esphome/components/socket/headers.h | 2 +- .../components/socket/lwip_raw_tcp_impl.cpp | 2 +- esphome/components/spi/__init__.py | 14 +- esphome/components/spi/spi.h | 2 +- esphome/components/spi/spi_arduino.cpp | 6 +- esphome/components/time/real_time_clock.cpp | 2 +- esphome/components/uart/__init__.py | 10 +- ...nent_rp2040.cpp => uart_component_rp2.cpp} | 24 +- ...omponent_rp2040.h => uart_component_rp2.h} | 6 +- esphome/components/wake_on_lan/button.py | 2 +- esphome/components/watchdog/watchdog.cpp | 6 +- esphome/components/web_server/__init__.py | 4 +- .../components/web_server_base/__init__.py | 2 +- esphome/components/wifi/__init__.py | 18 +- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 6 +- .../components/wifi/wifi_component_pico_w.cpp | 2 +- esphome/config_validation.py | 118 +++- esphome/const.py | 13 +- esphome/core/__init__.py | 38 +- esphome/core/config.py | 4 +- esphome/core/defines.h | 13 +- esphome/core/hal.h | 4 +- esphome/core/helpers.h | 10 +- esphome/core/preference_backend.h | 6 +- esphome/core/preferences.h | 4 +- esphome/core/wake.h | 6 +- .../wake/{wake_rp2040.cpp => wake_rp2.cpp} | 4 +- .../core/wake/{wake_rp2040.h => wake_rp2.h} | 6 +- esphome/storage_json.py | 2 +- esphome/wizard.py | 56 +- script/build_language_schema.py | 23 + script/ci-custom.py | 7 +- script/determine-jobs.py | 19 +- ...p2040-boards.py => generate-rp2-boards.py} | 12 +- ...40-pico2-ard.yaml => test.rp2350-ard.yaml} | 0 .../{rp2040 => rp2}/test.rp2040-ard.yaml | 2 +- .../test.rp2350-ard.yaml} | 2 +- ...40-pico2-ard.yaml => test.rp2350-ard.yaml} | 0 tests/script/test_determine_jobs.py | 27 +- .../build_components_base.rp2040-ard.yaml | 2 +- ... => build_components_base.rp2350-ard.yaml} | 2 +- ...{rp2040-pico2-ard.yaml => rp2350-ard.yaml} | 0 tests/unit_tests/components/test_rp2.py | 95 +++ tests/unit_tests/components/test_rp2040.py | 92 --- ..._boards.py => test_rp2_generate_boards.py} | 4 +- tests/unit_tests/components/test_wifi.py | 6 +- tests/unit_tests/test_config_validation.py | 204 ++++++- tests/unit_tests/test_core.py | 30 + tests/unit_tests/test_loader.py | 544 ++++-------------- tests/unit_tests/test_main.py | 81 ++- tests/unit_tests/test_wizard.py | 56 +- tests/unit_tests/test_writer.py | 4 +- 153 files changed, 1297 insertions(+), 1062 deletions(-) rename esphome/components/adc/{adc_sensor_rp2040.cpp => adc_sensor_rp2.cpp} (97%) rename esphome/components/debug/{debug_rp2040.cpp => debug_rp2.cpp} (93%) rename esphome/components/ethernet/{ethernet_component_rp2040.cpp => ethernet_component_rp2.cpp} (98%) rename esphome/components/internal_temperature/{internal_temperature_rp2040.cpp => internal_temperature_rp2.cpp} (85%) rename esphome/components/logger/{logger_rp2040.cpp => logger_rp2.cpp} (85%) rename esphome/components/logger/{logger_rp2040.h => logger_rp2.h} (94%) rename esphome/components/mdns/{mdns_rp2040.cpp => mdns_rp2.cpp} (94%) rename esphome/components/ota/{ota_backend_arduino_rp2040.cpp => ota_backend_arduino_rp2.cpp} (70%) rename esphome/components/ota/{ota_backend_arduino_rp2040.h => ota_backend_arduino_rp2.h} (78%) rename esphome/components/{rp2040 => rp2}/__init__.py (92%) rename esphome/components/{rp2040 => rp2}/boards.jinja2 (56%) rename esphome/components/{rp2040 => rp2}/boards.py (99%) rename esphome/components/{rp2040 => rp2}/build_pio.py.script (100%) rename esphome/components/{rp2040 => rp2}/const.py (91%) create mode 100644 esphome/components/rp2/core.cpp rename esphome/components/{rp2040 => rp2}/core.h (53%) rename esphome/components/{rp2040 => rp2}/crash_handler.cpp (97%) rename esphome/components/{rp2040 => rp2}/crash_handler.h (66%) rename esphome/components/{rp2040 => rp2}/generate_boards.py (98%) rename esphome/components/{rp2040 => rp2}/gpio.cpp (81%) rename esphome/components/{rp2040 => rp2}/gpio.h (85%) rename esphome/components/{rp2040 => rp2}/gpio.py (82%) rename esphome/components/{rp2040 => rp2}/hal.cpp (58%) rename esphome/components/{rp2040 => rp2}/hal.h (96%) rename esphome/components/{rp2040 => rp2}/helpers.cpp (98%) rename esphome/components/{rp2040 => rp2}/inject_lwip_include.py.script (100%) rename esphome/components/{rp2040 => rp2}/lwipopts.h.jinja (100%) rename esphome/components/{rp2040 => rp2}/post_build.py.script (100%) create mode 100644 esphome/components/rp2/preference_backend.h rename esphome/components/{rp2040 => rp2}/preferences.cpp (82%) rename esphome/components/{rp2040 => rp2}/preferences.h (59%) rename esphome/components/{rp2040 => rp2}/printf_stubs.cpp (94%) delete mode 100644 esphome/components/rp2040/core.cpp delete mode 100644 esphome/components/rp2040/preference_backend.h rename esphome/components/{rp2040_pio => rp2_pio}/__init__.py (98%) rename esphome/components/uart/{uart_component_rp2040.cpp => uart_component_rp2.cpp} (91%) rename esphome/components/uart/{uart_component_rp2040.h => uart_component_rp2.h} (88%) rename esphome/core/wake/{wake_rp2040.cpp => wake_rp2.cpp} (97%) rename esphome/core/wake/{wake_rp2040.h => wake_rp2.h} (88%) rename script/{generate-rp2040-boards.py => generate-rp2-boards.py} (77%) rename tests/components/adc/{test.rp2040-pico2-ard.yaml => test.rp2350-ard.yaml} (100%) rename tests/components/{rp2040 => rp2}/test.rp2040-ard.yaml (97%) rename tests/components/{rp2040/test.rp2040-pico2-ard.yaml => rp2/test.rp2350-ard.yaml} (90%) rename tests/components/spi/{test.rp2040-pico2-ard.yaml => test.rp2350-ard.yaml} (100%) rename tests/test_build_components/{build_components_base.rp2040-pico2-ard.yaml => build_components_base.rp2350-ard.yaml} (97%) rename tests/test_build_components/common/spi/{rp2040-pico2-ard.yaml => rp2350-ard.yaml} (100%) create mode 100644 tests/unit_tests/components/test_rp2.py delete mode 100644 tests/unit_tests/components/test_rp2040.py rename tests/unit_tests/components/{test_rp2040_generate_boards.py => test_rp2_generate_boards.py} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11e29db94a8..0fd6a79cb5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: script/build_codeowners.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check - script/generate-rp2040-boards.py --check + script/generate-rp2-boards.py --check script/ci_check_duplicate_test_ids.py import-time: diff --git a/CODEOWNERS b/CODEOWNERS index 571f8492f15..34ec4bc2bdf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -426,7 +426,7 @@ esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt esphome/components/router/speaker/* @kahrendt -esphome/components/rp2040/* @jesserockz +esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz diff --git a/esphome/__main__.py b/esphome/__main__.py index 2cc904ff4b4..4abd18d2398 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -355,7 +355,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.is_rp2040 + and CORE.is_rp2 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -402,7 +402,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.is_rp2040 + and CORE.is_rp2 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -985,7 +985,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.is_rp2040: + if CORE.is_rp2: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1173,7 +1173,7 @@ def upload_program( if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.is_rp2040 or CORE.is_libretiny: + elif CORE.is_rp2 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1647,7 +1647,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if successful_device is None and CORE.is_rp2040: + if successful_device is None and CORE.is_rp2: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() diff --git a/esphome/components/__init__.py b/esphome/components/__init__.py index e69de29bb2d..3d7a5462530 100644 --- a/esphome/components/__init__.py +++ b/esphome/components/__init__.py @@ -0,0 +1,6 @@ +# Importing `esphome.loader` here installs the component-alias +# ``sys.meta_path`` finder before any submodule lookup runs. Without this, +# `from esphome.components import ` from a fresh interpreter +# can race the finder install and raise ImportError, since the legacy +# alias dir no longer exists on disk. +from esphome import loader as _loader # noqa: F401 diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 96c8334a6d9..555d511f6eb 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { def validate_adc_pin(value): if str(value).upper() == "VCC": - if CORE.is_rp2040: + if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") if str(value).upper() == "TEMPERATURE": - return cv.only_on_rp2040("TEMPERATURE") + return cv.only_on_rp2("TEMPERATURE") if CORE.is_esp32: conf = pins.internal_gpio_input_pin_schema(value) @@ -261,11 +261,11 @@ def validate_adc_pin(value): raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC") return conf - if CORE.is_rp2040: + if CORE.is_rp2: conf = pins.internal_gpio_input_pin_schema(value) number = conf[CONF_NUMBER] if number not in (26, 27, 28, 29): - raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC") + raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC") return conf if CORE.is_libretiny: diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 03de6f8b4b1..71318987479 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v void set_autorange(bool autorange) { this->autorange_ = autorange; } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_is_temperature() { this->is_temperature_ = true; } -#endif // USE_RP2040 +#endif // USE_RP2 protected: uint8_t sample_count_{1}; @@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v static adc_oneshot_unit_handle_t shared_adc_handles[2]; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 bool is_temperature_{false}; -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ZEPHYR const struct adc_dt_spec *channel_ = nullptr; diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2.cpp similarity index 97% rename from esphome/components/adc/adc_sensor_rp2040.cpp rename to esphome/components/adc/adc_sensor_rp2.cpp index 894c346588c..6cb9ef113f6 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "adc_sensor.h" #include "esphome/core/log.h" @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2040"; +static const char *const TAG = "adc.rp2"; void ADCSensor::setup() { static bool initialized = false; @@ -102,4 +102,4 @@ float ADCSensor::sample() { } // namespace esphome::adc -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 09e09f0dc1b..86e2b771abe 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "adc_sensor_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 1146b435968..11ada7e970f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -300,7 +300,7 @@ CONFIG_SCHEMA = cv.All( CONF_LISTEN_BACKLOG, esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets esp32=4, # More RAM (520KB), BSD sockets - rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 bk72xx=4, # Moderate RAM, BSD-style sockets rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources @@ -311,7 +311,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes esp32=5, # 520KB RAM available - rp2040=4, # 264KB RAM but LWIP constraints + rp2=4, # 264KB RAM but LWIP constraints bk72xx=5, # Moderate RAM rtl87xx=5, # Moderate RAM host=8, # Abundant resources @@ -326,7 +326,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast esp32=8, # More RAM, can buffer more - rp2040=8, # Moderate RAM + rp2=8, # Moderate RAM bk72xx=8, # Moderate RAM nrf52=8, # Moderate RAM rtl87xx=8, # Moderate RAM diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index acdf24e747e..cb7d1b9d1e0 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1759,7 +1759,7 @@ bool APIConnection::send_device_info_response_() { // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) #define ESPHOME_MANUFACTURER "Espressif" -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) #define ESPHOME_MANUFACTURER "Beken" diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 92f7065730c..dae5fc92fd0 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -18,8 +18,8 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif #ifdef USE_ESP8266_CRASH_HANDLER #include "esphome/components/esp8266/crash_handler.h" @@ -279,8 +279,8 @@ class APIConnection final : public APIServerConnectionBase { esp32::crash_handler_log(); esp32::crash_handler_clear(); #endif -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_log(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_log(); #endif #ifdef USE_ESP8266_CRASH_HANDLER esp8266::crash_handler_log(); diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 2a07903b687..22d544ba374 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]: if ( not CORE.is_esp32 and not CORE.is_esp8266 - and not CORE.is_rp2040 + and not CORE.is_rp2 and not CORE.is_libretiny ): return ["socket"] @@ -37,7 +37,7 @@ async def to_code(config): elif CORE.is_esp8266: # https://github.com/ESP32Async/ESPAsyncTCP cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") - elif CORE.is_rp2040: + elif CORE.is_rp2: # https://github.com/ayushsharma82/RPAsyncTCP # RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better # ESPAsyncWebServer compatibility @@ -47,6 +47,6 @@ async def to_code(config): def FILTER_SOURCE_FILES() -> list[str]: # Exclude socket implementation for platforms that use AsyncTCP libraries - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny: return ["async_tcp_socket.cpp"] return [] diff --git a/esphome/components/async_tcp/async_tcp.h b/esphome/components/async_tcp/async_tcp.h index 21fcfe239fc..0906a078447 100644 --- a/esphome/components/async_tcp/async_tcp.h +++ b/esphome/components/async_tcp/async_tcp.h @@ -7,7 +7,7 @@ #elif defined(USE_ESP8266) // Use ESPAsyncTCP library for ESP8266 (always Arduino) #include -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Use RPAsyncTCP library for RP2040 #include #else diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index e8c0f163b39..10cbc981c71 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -1,6 +1,6 @@ #include "async_tcp_socket.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/network/util.h" diff --git a/esphome/components/async_tcp/async_tcp_socket.h b/esphome/components/async_tcp/async_tcp_socket.h index 28714a77522..3b17fe14df0 100644 --- a/esphome/components/async_tcp/async_tcp_socket.h +++ b/esphome/components/async_tcp/async_tcp_socket.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/socket/socket.h" diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cd877fc8799..703ae983926 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), @@ -105,7 +105,7 @@ async def to_code(config): if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2040): + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): cg.add_library("DNSServer", None) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index dc032f442e1..3e94d04f21c 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -70,7 +70,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "debug_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "debug_host.cpp": {PlatformFramework.HOST_NATIVE}, - "debug_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "debug_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "debug_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2.cpp similarity index 93% rename from esphome/components/debug/debug_rp2040.cpp rename to esphome/components/debug/debug_rp2.cpp index adc23dbf51f..ba6081963f2 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,5 +1,5 @@ #include "debug_component.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" #include "esphome/core/log.h" #include @@ -9,8 +9,8 @@ #else #include #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif namespace esphome::debug { @@ -41,8 +41,8 @@ const char *DebugComponent::get_reset_reason_(std::span None: cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) cg.add_define("USE_ETHERNET_SPI") - cg.add_library(_RP2040_SPI_LIBRARIES[config[CONF_TYPE]], None) + cg.add_library(_RP2_SPI_LIBRARIES[config[CONF_TYPE]], None) def _final_validate_rmii_pins(config: ConfigType) -> None: @@ -752,7 +754,7 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, - "ethernet_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ethernet_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "esp_eth_phy_jl1101.c": { PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index e0fe920ea16..16f09a45f0d 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -25,7 +25,7 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #endif #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 #if defined(USE_ETHERNET_W5500) #include #elif defined(USE_ETHERNET_W5100) @@ -182,14 +182,14 @@ class EthernetComponent final : public Component { #endif // USE_ETHERNET_SPI #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_clk_pin(uint8_t clk_pin); void set_miso_pin(uint8_t miso_pin); void set_mosi_pin(uint8_t mosi_pin); void set_cs_pin(uint8_t cs_pin); void set_interrupt_pin(int8_t interrupt_pin); void set_reset_pin(int8_t reset_pin); -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -272,7 +272,7 @@ class EthernetComponent final : public Component { esp_eth_phy_t *phy_{nullptr}; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls #if defined(USE_ETHERNET_W5100) static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time @@ -301,7 +301,7 @@ class EthernetComponent final : public Component { uint8_t cs_pin_; int8_t interrupt_pin_{-1}; int8_t reset_pin_{-1}; -#endif // USE_RP2040 +#endif // USE_RP2 // Common members #ifdef USE_ETHERNET_MANUAL_IP diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp similarity index 98% rename from esphome/components/ethernet/ethernet_component_rp2040.cpp rename to esphome/components/ethernet/ethernet_component_rp2.cpp index 250297ddb5c..d2e3f14e02e 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -1,12 +1,12 @@ #include "ethernet_component.h" -#if defined(USE_ETHERNET) && defined(USE_RP2040) +#if defined(USE_ETHERNET) && defined(USE_RP2) #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/components/rp2040/gpio.h" +#include "esphome/components/rp2/gpio.h" #include #include @@ -29,7 +29,7 @@ void EthernetComponent::setup() { // Toggle reset pin if configured if (this->reset_pin_ >= 0) { - rp2040::RP2040GPIOPin reset_pin; + rp2::RP2GPIOPin reset_pin; reset_pin.set_pin(this->reset_pin_); reset_pin.set_flags(gpio::FLAG_OUTPUT); reset_pin.setup(); @@ -380,4 +380,4 @@ void EthernetComponent::disable() { } // namespace esphome::ethernet -#endif // USE_ETHERNET && USE_RP2040 +#endif // USE_ETHERNET && USE_RP2 diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index cd4134e9aec..bceaf6e40f5 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -7,7 +7,7 @@ #include -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) namespace esphome::factory_reset { @@ -73,4 +73,4 @@ void FactoryResetComponent::setup() { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index d80d2d2406c..b0a899c7194 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" #include "esphome/core/preferences.h" -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) #ifdef USE_ESP32 #include @@ -32,4 +32,4 @@ class FactoryResetComponent final : public Component { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 2f1aa936a3c..43358baedba 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -47,7 +47,7 @@ CONFIG_SCHEMA = ( host=True, ln882x=False, nrf52=True, - rp2040=True, + rp2=True, rtl87xx=False, ): cv.boolean, cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum( diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp index c113cb48a6f..d8e1f059a6c 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.cpp +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -1,6 +1,6 @@ #include #include "hmac_sha256.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" namespace esphome::hmac_sha256 { diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h index 22129b1182f..74ac4c23ded 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.h +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index fd033dac7f1..54d7f5c77b2 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -73,7 +73,7 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: + if CORE.is_rp2 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" if ( @@ -96,7 +96,7 @@ def _declare_request_class(value): return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: return cv.declare_id(HttpRequestIDF)(value) - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return cv.declare_id(HttpRequestArduino)(value) return NotImplementedError @@ -118,7 +118,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, cv.Optional(CONF_WATCHDOG_TIMEOUT): cv.All( - cv.Any(cv.only_on_esp32, cv.only_on_rp2040), + cv.Any(cv.only_on_esp32, cv.only_on_rp2), cv.positive_not_null_time_period, cv.positive_time_period_milliseconds, ), @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), validate_ssl_verification, @@ -204,7 +204,7 @@ async def to_code(config): ) if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("HTTPClient", None) if CORE.is_host: if IS_MACOS: @@ -368,7 +368,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "http_request_host.cpp": {PlatformFramework.HOST_NATIVE}, "http_request_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index bb5e9427dd1..1760cb93955 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -72,7 +72,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur bool status = container->client_.begin(*stream_ptr, url.c_str()); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 8da40798eca..c109de8a39c 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -4,7 +4,7 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) -#if defined(USE_RP2040) +#if defined(USE_RP2) #include #include #endif diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index 1bb54599dcc..b7026e0f55b 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), ), ) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index eec2211a960..7b163d065eb 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -49,7 +49,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -130,7 +130,7 @@ def validate_config(config): return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) - if CORE.is_rp2040: + if CORE.is_rp2: sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) if sda_controller != scl_controller: @@ -171,7 +171,7 @@ CONFIG_SCHEMA = cv.All( CONF_SDA, esp32="SDA", esp8266="SDA", - rp2040="SDA", + rp2="SDA", nrf52="SDA", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( @@ -181,7 +181,7 @@ CONFIG_SCHEMA = cv.All( CONF_SCL, esp32="SCL", esp8266="SCL", - rp2040="SCL", + rp2="SCL", nrf52="SCL", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( @@ -191,7 +191,7 @@ CONFIG_SCHEMA = cv.All( CONF_FREQUENCY, esp32="50kHz", esp8266="50kHz", - rp2040="50kHz", + rp2="50kHz", nrf52="100kHz", host="50kHz", ): cv.All( @@ -219,7 +219,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_NRF52, PLATFORM_HOST, ] @@ -233,7 +233,7 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") - if CORE.is_rp2040: + if CORE.is_rp2: if len(full_config) > 2: raise cv.Invalid( "The maximum number of I2C interfaces for RP2040/RP2350 is 2" @@ -443,7 +443,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 47a06abe9ec..871f67a4c8f 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -19,7 +19,7 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Select Wire instance based on pin assignment, not definition order. // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf @@ -41,7 +41,7 @@ void ArduinoI2CBus::setup() { } void ArduinoI2CBus::set_pins_and_clock_() { -#ifdef USE_RP2040 +#ifdef USE_RP2 wire_->setSDA(this->sda_pin_); wire_->setSCL(this->scl_pin_); wire_->begin(); @@ -52,7 +52,7 @@ void ArduinoI2CBus::set_pins_and_clock_() { #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h wire_->setClockStretchLimit(timeout_); // unit: us -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h wire_->setTimeout(timeout_ / 1000); // unit: ms #endif @@ -70,7 +70,7 @@ void ArduinoI2CBus::dump_config() { if (timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); #endif } diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp similarity index 85% rename from esphome/components/internal_temperature/internal_temperature_rp2040.cpp rename to esphome/components/internal_temperature/internal_temperature_rp2.cpp index 66dee9faf78..11f8e27fc33 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/log.h" #include "internal_temperature.h" @@ -7,7 +7,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2040"; +static const char *const TAG = "internal_temperature.rp2"; void InternalTemperatureSensor::update() { float temperature = NAN; @@ -28,4 +28,4 @@ void InternalTemperatureSensor::update() { } // namespace esphome::internal_temperature -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 02730b68624..805138071eb 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -10,7 +10,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, PlatformFramework, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on( [ PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_NRF52, PLATFORM_LN882X, @@ -58,7 +58,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "internal_temperature_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "internal_temperature_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "internal_temperature_bk72xx.cpp": { PlatformFramework.BK72XX_ARDUINO, }, diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9629dce0bf9..77a875dd8fc 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -154,7 +154,7 @@ HARDWARE_UART_TO_SERIAL = { UART2: cg.global_ns.Serial2, DEFAULT: cg.global_ns.Serial, }, - PLATFORM_RP2040: { + PLATFORM_RP2: { UART0: cg.global_ns.Serial1, UART1: cg.global_ns.Serial2, USB_CDC: cg.global_ns.Serial, @@ -171,7 +171,7 @@ def uart_selection(value): return cv.one_of(*UART_SELECTION_ESP32[variant], upper=True)(value) if CORE.is_esp8266: return cv.one_of(*UART_SELECTION_ESP8266, upper=True)(value) - if CORE.is_rp2040: + if CORE.is_rp2: return cv.one_of(*UART_SELECTION_RP2040, upper=True)(value) if CORE.is_libretiny: family = get_libretiny_family() @@ -282,7 +282,7 @@ CONFIG_SCHEMA = cv.All( esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, esp32_s31=USB_SERIAL_JTAG, - rp2040=USB_CDC, + rp2=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, rtl87xx=DEFAULT, @@ -292,7 +292,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP8266, PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, @@ -417,11 +417,7 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_ESP8266_LOGGER_SERIAL1") enable_serial1() - if ( - (CORE.is_esp8266 or CORE.is_rp2040) - and has_serial_logging - and is_at_least_verbose - ): + if (CORE.is_esp8266 or CORE.is_rp2) and has_serial_logging and is_at_least_verbose: debug_serial_port = HARDWARE_UART_TO_SERIAL[CORE.target_platform][ config.get(CONF_HARDWARE_UART) ] @@ -605,7 +601,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "logger_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "logger_host.cpp": {PlatformFramework.HOST_NATIVE}, - "logger_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "logger_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "logger_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 684da0202e4..6527b6aa8cd 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -206,7 +206,7 @@ void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) UARTSelection Logger::get_uart() const { return this->uart_; } #endif diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 784cbea67ee..69d8e6d32af 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -23,10 +23,10 @@ #if defined(USE_ESP8266) #include #endif // USE_ESP8266 -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO #ifdef USE_ESP32 @@ -96,7 +96,7 @@ struct CStrCompare { // macOS allows up to 64 bytes, Linux up to 16 static constexpr size_t THREAD_NAME_BUF_SIZE = 64; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * * Advanced configuration (pin selection, etc) is not supported. @@ -122,7 +122,7 @@ enum UARTSelection : uint8_t { UART_SELECTION_UART0_SWAP, #endif // USE_ESP8266 }; -#endif // USE_ESP32 || USE_ESP8266 || USE_RP2040 || USE_LIBRETINY || USE_ZEPHYR +#endif // USE_ESP32 || USE_ESP8266 || USE_RP2 || USE_LIBRETINY || USE_ZEPHYR /** * @brief Logger component for all ESPHome logging. @@ -160,7 +160,7 @@ class Logger final : public Component { #ifdef USE_HOST void create_pthread_key() { pthread_key_create(&log_recursion_key_, nullptr); } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. UARTSelection get_uart() const; @@ -351,7 +351,7 @@ class Logger final : public Component { #endif // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) UARTSelection uart_{UART_SELECTION_UART0}; #endif #ifdef USE_LIBRETINY @@ -505,8 +505,8 @@ class LoggerMessageTrigger final : public Triggerdigest_, 0, 16); MD5Init(&this->ctx_); @@ -14,7 +14,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { MD5Update(&this->ctx_, data, len); } void MD5Digest::calculate() { MD5Final(this->digest_, &this->ctx_); } -#endif // USE_ARDUINO && !USE_RP2040 +#endif // USE_ARDUINO && !USE_RP2 #ifdef USE_ESP32 void MD5Digest::init() { @@ -27,7 +27,7 @@ void MD5Digest::add(const uint8_t *data, size_t len) { esp_rom_md5_update(&this- void MD5Digest::calculate() { esp_rom_md5_final(this->digest_, &this->ctx_); } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void MD5Digest::init() { memset(this->digest_, 0, 16); br_md5_init(&this->ctx_); @@ -36,7 +36,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_, data, len); } void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_HOST MD5Digest::~MD5Digest() { diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 5e841edd838..ff0f2852c8d 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -19,7 +19,7 @@ #define MD5_CTX_TYPE md5_context_t #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #define MD5_CTX_TYPE br_md5_context #endif diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2de67542b24..3670098bcfc 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -70,11 +70,11 @@ def _require_network_interface(config: ConfigType) -> ConfigType: window. Reject at config time rather than silently producing a component that never initializes. """ - if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): return config full_config = fv.full_config.get() has_wifi = "wifi" in full_config - has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + has_ethernet = CORE.is_rp2 and "ethernet" in full_config if not (has_wifi or has_ethernet): options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" raise cv.Invalid( @@ -192,18 +192,18 @@ async def to_code(config): if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("LEAmDNS", None) # Subscribe to the network IP state listener(s) so MDNS.update() is only # scheduled during the probe+announce phase. Same on_ip_state() override # serves both WiFi and Ethernet (signatures match). - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: if "wifi" in CORE.config: from esphome.components import wifi wifi.request_wifi_ip_state_listener() - if CORE.is_rp2040 and "ethernet" in CORE.config: + if CORE.is_rp2 and "ethernet" in CORE.config: from esphome.components import ethernet ethernet.request_ethernet_ip_state_listener() @@ -274,7 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "mdns_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "mdns_host.cpp": {PlatformFramework.HOST_NATIVE}, - "mdns_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "mdns_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "mdns_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e11cb1abaa1..02b825605c1 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -100,7 +100,7 @@ void MDNSComponent::compile_records_(StaticVector services_{}; #endif -#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) +#if defined(USE_RP2) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2.cpp similarity index 94% rename from esphome/components/mdns/mdns_rp2040.cpp rename to esphome/components/mdns/mdns_rp2.cpp index f5848893a34..7eaac594fb5 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -1,5 +1,5 @@ #include "esphome/core/defines.h" -#if defined(USE_RP2040) && defined(USE_MDNS) +#if defined(USE_RP2) && defined(USE_MDNS) #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" @@ -17,7 +17,7 @@ namespace esphome::mdns { -static void register_rp2040(MDNSComponent *, StaticVector &services) { +static void register_rp2(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -82,7 +82,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: return; } if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); + this->setup_buffers_and_register_(register_rp2); this->initialized_ = true; } else { MDNS.notifyAPChange(); diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index afc514609cc..3bbc1cdfa3b 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -319,7 +319,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) device_info[MQTT_DEVICE_MANUFACTURER] = "Raspberry Pi"; #elif defined(USE_BK72XX) device_info[MQTT_DEVICE_MANUFACTURER] = "Beken"; diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index d2683e4bba8..616a1892265 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -124,7 +124,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=False, esp8266=False, host=False, - rp2040=False, + rp2=False, nrf52=True, ): cv.All( cv.boolean, @@ -135,7 +135,7 @@ CONFIG_SCHEMA = cv.Schema( esp32_arduino=cv.Version(0, 0, 0), esp8266_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), nrf52_zephyr=cv.Version(0, 0, 0), ), cv.boolean_false, @@ -263,7 +263,7 @@ async def to_code(config): cg.add_build_flag("-DCONFIG_IPV6") if CORE.is_esp8266: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") - if CORE.is_rp2040: + if CORE.is_rp2: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 38f449dc038..d51155b0a4c 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,7 +19,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index ee4d5abb1cc..d47c2e8b445 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( # esp8266_arduino=cv.Version(2, 7, 0), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(4, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), runtime_image.validate_runtime_image_settings, diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 83d8c611d5e..8296410f2fc 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -99,7 +99,7 @@ async def to_code(config): cg.add_define("USE_OTA") CORE.add_job(final_step) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("Updater", None) @@ -158,7 +158,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "ota_backend_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ota_backend_arduino_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp similarity index 70% rename from esphome/components/ota/ota_backend_arduino_rp2040.cpp rename to esphome/components/ota/ota_backend_arduino_rp2.cpp index 0ca0602519d..b35eb38c12c 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -1,9 +1,9 @@ #ifdef USE_ARDUINO -#ifdef USE_RP2040 -#include "ota_backend_arduino_rp2040.h" +#ifdef USE_RP2 +#include "ota_backend_arduino_rp2.h" #include "ota_backend.h" -#include "esphome/components/rp2040/preferences.h" +#include "esphome/components/rp2/preferences.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -11,11 +11,11 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2040"; +static const char *const TAG = "ota.arduino_rp2"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { +OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -23,7 +23,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); if (ret) { - rp2040::preferences_prevent_write(true); + rp2::preferences_prevent_write(true); return OTA_RESPONSE_OK; } @@ -42,12 +42,12 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { +void ArduinoRP2OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); this->md5_set_ = true; } -OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { +OTAResponseTypes ArduinoRP2OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); if (written == len) { return OTA_RESPONSE_OK; @@ -59,7 +59,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } -OTAResponseTypes ArduinoRP2040OTABackend::end() { +OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { @@ -72,11 +72,11 @@ OTAResponseTypes ArduinoRP2040OTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoRP2040OTABackend::abort() { +void ArduinoRP2OTABackend::abort() { Update.end(); - rp2040::preferences_prevent_write(false); + rp2::preferences_prevent_write(false); } } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2.h similarity index 78% rename from esphome/components/ota/ota_backend_arduino_rp2040.h rename to esphome/components/ota/ota_backend_arduino_rp2.h index d04d5c1a844..f7c0037bd2f 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -1,6 +1,6 @@ #pragma once #ifdef USE_ARDUINO -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "ota_backend.h" #include "esphome/core/defines.h" @@ -8,7 +8,7 @@ namespace esphome::ota { -class ArduinoRP2040OTABackend final { +class ArduinoRP2OTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); @@ -21,8 +21,8 @@ class ArduinoRP2040OTABackend final { bool md5_set_{false}; }; -std::unique_ptr make_ota_backend(); +std::unique_ptr make_ota_backend(); } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index 7c79f027027..c543983d8d1 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -8,8 +8,8 @@ #include "ota_backend_esp8266.h" #elif defined(USE_ESP32) #include "ota_backend_esp_idf.h" -#elif defined(USE_RP2040) -#include "ota_backend_arduino_rp2040.h" +#elif defined(USE_RP2) +#include "ota_backend_arduino_rp2.h" #elif defined(USE_LIBRETINY) #include "ota_backend_arduino_libretiny.h" #elif defined(USE_HOST) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 53a0f8fb778..ad9c4b5a185 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -118,7 +118,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", - rp2040="1000b", + rp2="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, @@ -248,7 +248,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index 222dae8f7f8..36152d88547 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_receiver { diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 2ed6a4c251b..f9ec054fe31 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -14,7 +14,7 @@ namespace esphome::remote_receiver { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); @@ -93,11 +93,11 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, std::string error_string_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) RemoteReceiverComponentStore store_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) HighFrequencyLoopRequester high_freq_; #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 1163fc86eb8..521c3daf873 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,7 +185,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 51a3c0b1d47..49c711330b0 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index bcb07038ea9..e2d33d13cc4 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -64,7 +64,7 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2/__init__.py similarity index 92% rename from esphome/components/rp2040/__init__.py rename to esphome/components/rp2/__init__.py index e76ce6def88..21a885a7cfd 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, - PLATFORM_RP2040, + PLATFORM_RP2, ThreadModel, ) from esphome.core import ( @@ -40,27 +40,34 @@ from .const import ( KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, - KEY_RP2040, + KEY_RP2, KEY_VARIANT, MCU_TO_VARIANT, STANDARD_BOARDS, VARIANT_FRIENDLY, VARIANTS, - rp2040_ns, + rp2_ns, ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa: F401 +from .gpio import rp2_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +# Legacy top-level YAML keys that route here. The framework +# (esphome/loader.py + esphome/config.py) handles both the deprecation +# warning and the key-rename pass; this declaration is the only place a +# component needs to opt in. See ComponentManifest.aliases for details. +ALIASES = ["rp2040"] +ALIAS_REMOVAL_VERSION = "2027.7.0" + def get_board() -> str: """Return the configured board name.""" - return CORE.data[KEY_RP2040][KEY_BOARD] + return CORE.data[KEY_RP2][KEY_BOARD] def board_has_wifi() -> bool: @@ -90,22 +97,22 @@ def board_id_has_wifi(board_id: str) -> bool: def set_core_data(config: ConfigType) -> ConfigType: - CORE.data[KEY_RP2040] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 + CORE.data[KEY_RP2] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) - CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] - CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] + CORE.data[KEY_RP2][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2][KEY_VARIANT] = config[CONF_VARIANT] - CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} + CORE.data[KEY_RP2][KEY_PIO_FILES] = {} return config def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: - return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + return (core_obj or CORE).data[KEY_RP2][KEY_VARIANT] def only_on_variant( @@ -121,7 +128,7 @@ def only_on_variant( unsupported = [unsupported] def validator_(obj: Any) -> Any: - if not CORE.is_rp2040: + if not CORE.is_rp2: raise cv.Invalid(f"{msg_prefix} is only available on RP2040") variant = get_rp2040_variant() if supported is not None and variant not in supported: @@ -306,13 +313,18 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): - cg.add(rp2040_ns.setup_preferences()) + cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor # conditionals cg.add_platformio_option("lib_ldf_mode", "chain+") cg.add_platformio_option("lib_compat_mode", "strict") cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_build_flag("-DUSE_RP2") + # USE_RP2040 kept defined as a backwards-compat alias for external + # custom components that may still test for it. Internal code uses + # USE_RP2 (the canonical name for the RP2 chip family — covers + # RP2040, RP2350, and any future RP2-series chips). cg.add_build_flag("-DUSE_RP2040") cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") @@ -327,7 +339,8 @@ async def to_code(config): conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") - cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") # back-compat alias # cg.add_build_flag("-DPICO_BOARD=pico_w") cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( @@ -359,8 +372,12 @@ async def to_code(config): cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) - cg.add_define("USE_RP2040_CRASH_HANDLER") + cg.add_define("USE_RP2_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define( + "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] + ) # back-compat alias + cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() @@ -465,7 +482,7 @@ def _configure_lwip() -> None: } # Store for copy_files() to generate the header - CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines + CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines # Add a pre-build extra script that injects our lwip_override directory # into CCFLAGS so our lwipopts.h shadows the framework's version. @@ -500,7 +517,7 @@ def _generate_lwipopts_h() -> None: """ from jinja2 import Environment - lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) + lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: return @@ -527,7 +544,7 @@ def add_pio_file(component: str, key: str, data: str): raise EsphomeError( f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/esphome/issues" ) from e - CORE.data[KEY_RP2040][KEY_PIO_FILES][key] = data + CORE.data[KEY_RP2][KEY_PIO_FILES][key] = data def generate_pio_files() -> bool: @@ -536,7 +553,7 @@ def generate_pio_files() -> bool: shutil.rmtree(CORE.relative_build_path("src/pio"), ignore_errors=True) includes: list[str] = [] - files = CORE.data[KEY_RP2040][KEY_PIO_FILES] + files = CORE.data[KEY_RP2][KEY_PIO_FILES] if not files: return False for key, data in files.items(): @@ -581,7 +598,7 @@ def copy_files(): # RP2040 crash handler stacktrace decoding -# Matches output from esphome/components/rp2040/crash_handler.cpp +# Matches output from esphome/components/rp2/crash_handler.cpp _CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT") _CRASH_ADDR_RE = re.compile( r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" diff --git a/esphome/components/rp2040/boards.jinja2 b/esphome/components/rp2/boards.jinja2 similarity index 56% rename from esphome/components/rp2040/boards.jinja2 rename to esphome/components/rp2/boards.jinja2 index 989fb83701a..9223009c267 100644 --- a/esphome/components/rp2040/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= {{ cyw43_gpio_offset }} to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = {{ cyw43_gpio_offset }} CYW43_MAX_GPIO = {{ cyw43_max_gpio }} DEFAULT_MAX_PIN = {{ default_max_pin }} -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { {%- for name, pins in board_pins %} {{ name | repr }}: {{ pins | format_pins }}, {%- endfor %} @@ -23,3 +23,10 @@ BOARDS = { }, {%- endfor %} } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2/boards.py similarity index 99% rename from esphome/components/rp2040/boards.py rename to esphome/components/rp2/boards.py index 0bc5c48d033..94d0ebbb600 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2/boards.py @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = 64 CYW43_MAX_GPIO = 66 DEFAULT_MAX_PIN = 29 -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { "0xcb_helios": { "LED": 17, "MISO": 20, @@ -2299,3 +2299,10 @@ BOARDS = { "max_pin": 29, }, } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/build_pio.py.script b/esphome/components/rp2/build_pio.py.script similarity index 100% rename from esphome/components/rp2040/build_pio.py.script rename to esphome/components/rp2/build_pio.py.script diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2/const.py similarity index 91% rename from esphome/components/rp2040/const.py rename to esphome/components/rp2/const.py index 959753d95b3..515f9f007c7 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2/const.py @@ -2,7 +2,7 @@ import esphome.codegen as cg KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" -KEY_RP2040 = "rp2040" +KEY_RP2 = "rp2" KEY_PIO_FILES = "pio_files" KEY_VARIANT = "variant" @@ -31,4 +31,4 @@ STANDARD_BOARDS = { VARIANT_RP2350: "rpipico2w", } -rp2040_ns = cg.esphome_ns.namespace("rp2040") +rp2_ns = cg.esphome_ns.namespace("rp2") diff --git a/esphome/components/rp2/core.cpp b/esphome/components/rp2/core.cpp new file mode 100644 index 00000000000..2509f47a867 --- /dev/null +++ b/esphome/components/rp2/core.cpp @@ -0,0 +1,6 @@ +#ifdef USE_RP2 + +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// rp2 — there is no extra component bootstrap to keep here. + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/core.h b/esphome/components/rp2/core.h similarity index 53% rename from esphome/components/rp2040/core.h rename to esphome/components/rp2/core.h index db8937a8a36..c53c3719ebd 100644 --- a/esphome/components/rp2040/core.h +++ b/esphome/components/rp2/core.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include extern "C" unsigned long ulMainGetRunTimeCounterValue(); -namespace esphome::rp2040 {} // namespace esphome::rp2040 +namespace esphome::rp2 {} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp similarity index 97% rename from esphome/components/rp2040/crash_handler.cpp rename to esphome/components/rp2/crash_handler.cpp index f9eb42a0f8a..5553a24a601 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -1,7 +1,7 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #include "esphome/core/log.h" @@ -51,9 +51,9 @@ static inline bool is_code_addr(uint32_t val) { static constexpr size_t MAX_BACKTRACE = 4; -namespace esphome::rp2040 { +namespace esphome::rp2 { -static const char *const TAG = "rp2040.crash"; +static const char *const TAG = "rp2.crash"; // Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). // The valid field is explicitly cleared in crash_handler_read_and_clear() instead. @@ -117,7 +117,7 @@ void crash_handler_log() { ESP_LOGE(TAG, "%s", hint); } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 // --- HardFault handler --- // Overrides the weak isr_hardfault from arduino-pico's crt0.S. @@ -236,5 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2/crash_handler.h similarity index 66% rename from esphome/components/rp2040/crash_handler.h rename to esphome/components/rp2/crash_handler.h index 78e8ede08c8..8c43d9fd3b0 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER -namespace esphome::rp2040 { +namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. void crash_handler_read_and_clear(); @@ -17,7 +17,7 @@ void crash_handler_log(); /// Returns true if crash data was found this boot. bool crash_handler_has_data(); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2/generate_boards.py similarity index 98% rename from esphome/components/rp2040/generate_boards.py rename to esphome/components/rp2/generate_boards.py index b1a0b17ca35..33eb1b30584 100644 --- a/esphome/components/rp2040/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -1,6 +1,6 @@ """Generate boards.py from arduino-pico board definitions. -Usage: python esphome/components/rp2040/generate_boards.py +Usage: python esphome/components/rp2/generate_boards.py """ import json diff --git a/esphome/components/rp2040/gpio.cpp b/esphome/components/rp2/gpio.cpp similarity index 81% rename from esphome/components/rp2040/gpio.cpp rename to esphome/components/rp2/gpio.cpp index 4b3c98104c5..0dbb124a267 100644 --- a/esphome/components/rp2040/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,12 +1,12 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "gpio.h" #include "esphome/core/log.h" namespace esphome { -namespace rp2040 { +namespace rp2 { -static const char *const TAG = "rp2040"; +static const char *const TAG = "rp2"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) @@ -30,7 +30,7 @@ struct ISRPinArg { bool inverted; }; -ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { +ISRInternalGPIOPin RP2GPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->pin_; arg->inverted = this->inverted_; @@ -38,7 +38,7 @@ ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -60,25 +60,23 @@ void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::Inte attachInterrupt(pin_, func, arduino_mode, arg); } -void RP2040GPIOPin::pin_mode(gpio::Flags flags) { +void RP2GPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags, pin_)); // NOLINT } -size_t RP2040GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "GPIO%u", this->pin_); -} +size_t RP2GPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%u", this->pin_); } -bool RP2040GPIOPin::digital_read() { +bool RP2GPIOPin::digital_read() { return bool(digitalRead(pin_)) != inverted_; // NOLINT } -void RP2040GPIOPin::digital_write(bool value) { +void RP2GPIOPin::digital_write(bool value) { digitalWrite(pin_, value != inverted_ ? 1 : 0); // NOLINT } -void RP2040GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } +void RP2GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace rp2040 +} // namespace rp2 -using namespace rp2040; +using namespace rp2; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); @@ -115,4 +113,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2/gpio.h similarity index 85% rename from esphome/components/rp2040/gpio.h rename to esphome/components/rp2/gpio.h index b9aa497b473..538fef619a2 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -1,13 +1,13 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include "esphome/core/hal.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040GPIOPin final : public InternalGPIOPin { +class RP2GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -32,6 +32,6 @@ class RP2040GPIOPin final : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.py b/esphome/components/rp2/gpio.py similarity index 82% rename from esphome/components/rp2040/gpio.py rename to esphome/components/rp2/gpio.py index 18fb09f76a4..e4db6a831c3 100644 --- a/esphome/components/rp2040/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -16,22 +16,22 @@ from esphome.const import ( from esphome.core import CORE from . import boards -from .const import KEY_BOARD, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_RP2, rp2_ns -RP2040GPIOPin = rp2040_ns.class_("RP2040GPIOPin", cg.InternalGPIOPin) +RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) def _lookup_pin(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] - board_pins = boards.RP2040_BOARD_PINS.get(board, {}) + board = CORE.data[KEY_RP2][KEY_BOARD] + board_pins = boards.RP2_BOARD_PINS.get(board, {}) while isinstance(board_pins, str): - board_pins = boards.RP2040_BOARD_PINS[board_pins] + board_pins = boards.RP2_BOARD_PINS[board_pins] if value in board_pins: return board_pins[value] - if value in boards.RP2040_BASE_PINS: - return boards.RP2040_BASE_PINS[value] + if value in boards.RP2_BASE_PINS: + return boards.RP2_BASE_PINS[value] raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") @@ -61,7 +61,7 @@ def _board_max_virtual_pin(board): def validate_gpio_pin(value): value = _translate_pin(value) - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) if max_virtual is not None and boards.CYW43_GPIO_OFFSET <= value <= max_virtual: return value @@ -72,7 +72,7 @@ def validate_gpio_pin(value): def validate_supports(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None or value[CONF_NUMBER] < boards.CYW43_GPIO_OFFSET @@ -89,9 +89,9 @@ def validate_supports(value): return value -RP2040_PIN_SCHEMA = cv.All( +RP2_PIN_SCHEMA = cv.All( pins.gpio_base_schema( - RP2040GPIOPin, + RP2GPIOPin, validate_gpio_pin, modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), @@ -99,8 +99,8 @@ RP2040_PIN_SCHEMA = cv.All( ) -@pins.PIN_SCHEMA_REGISTRY.register("rp2040", RP2040_PIN_SCHEMA) -async def rp2040_pin_to_code(config): +@pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) +async def rp2_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2/hal.cpp similarity index 58% rename from esphome/components/rp2040/hal.cpp rename to esphome/components/rp2/hal.cpp index e71d3fd54d6..28535cacbb8 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -1,23 +1,23 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #endif #include "hardware/watchdog.h" -// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check. +// Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the -// rp2040 component's API. -namespace esphome::rp2040 {} // namespace esphome::rp2040 +// rp2 component's API. +namespace esphome::rp2 {} // namespace esphome::rp2 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { @@ -26,11 +26,11 @@ void arch_restart() { } void arch_init() { -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_read_and_clear(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_read_and_clear(); #endif -#if USE_RP2040_WATCHDOG_TIMEOUT > 0 - watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); +#if USE_RP2_WATCHDOG_TIMEOUT > 0 + watchdog_enable(USE_RP2_WATCHDOG_TIMEOUT, false); #endif } @@ -38,4 +38,4 @@ uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/hal.h b/esphome/components/rp2/hal.h similarity index 96% rename from esphome/components/rp2040/hal.h rename to esphome/components/rp2/hal.h index c9c61c921da..b16f31d797b 100644 --- a/esphome/components/rp2040/hal.h +++ b/esphome/components/rp2/hal.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -25,7 +25,7 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); -namespace esphome::rp2040 {} +namespace esphome::rp2 {} namespace esphome { @@ -58,4 +58,4 @@ uint32_t arch_get_cpu_freq_hz(); } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2/helpers.cpp similarity index 98% rename from esphome/components/rp2040/helpers.cpp rename to esphome/components/rp2/helpers.cpp index 6e5ddad2364..a54bcf80f77 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2/helpers.cpp @@ -1,7 +1,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -89,4 +89,4 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/inject_lwip_include.py.script b/esphome/components/rp2/inject_lwip_include.py.script similarity index 100% rename from esphome/components/rp2040/inject_lwip_include.py.script rename to esphome/components/rp2/inject_lwip_include.py.script diff --git a/esphome/components/rp2040/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja similarity index 100% rename from esphome/components/rp2040/lwipopts.h.jinja rename to esphome/components/rp2/lwipopts.h.jinja diff --git a/esphome/components/rp2040/post_build.py.script b/esphome/components/rp2/post_build.py.script similarity index 100% rename from esphome/components/rp2040/post_build.py.script rename to esphome/components/rp2/post_build.py.script diff --git a/esphome/components/rp2/preference_backend.h b/esphome/components/rp2/preference_backend.h new file mode 100644 index 00000000000..c5e8a757da8 --- /dev/null +++ b/esphome/components/rp2/preference_backend.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef USE_RP2 + +#include +#include + +namespace esphome::rp2 { + +class RP2PreferenceBackend final { + public: + bool save(const uint8_t *data, size_t len); + bool load(uint8_t *data, size_t len); + + size_t offset = 0; + uint32_t type = 0; +}; + +class RP2Preferences; +RP2Preferences *get_preferences(); + +} // namespace esphome::rp2 + +namespace esphome { +using PreferenceBackend = rp2::RP2PreferenceBackend; +} // namespace esphome + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.cpp b/esphome/components/rp2/preferences.cpp similarity index 82% rename from esphome/components/rp2040/preferences.cpp rename to esphome/components/rp2/preferences.cpp index cfc802b28f5..778ce070a91 100644 --- a/esphome/components/rp2040/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -12,7 +12,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { static const char *const TAG = "preferences"; @@ -37,7 +37,7 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { return crc; } -bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { +bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -58,7 +58,7 @@ bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { return true; } -bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { +bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -80,27 +80,27 @@ bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { return true; } -RP2040Preferences::RP2040Preferences() : eeprom_sector_(&_EEPROM_start) {} +RP2Preferences::RP2Preferences() : eeprom_sector_(&_EEPROM_start) {} -void RP2040Preferences::setup() { +void RP2Preferences::setup() { ESP_LOGVV(TAG, "Loading preferences from flash"); memcpy(s_flash_storage, this->eeprom_sector_, RP2040_FLASH_STORAGE_SIZE); } -ESPPreferenceObject RP2040Preferences::make_preference(size_t length, uint32_t type) { +ESPPreferenceObject RP2Preferences::make_preference(size_t length, uint32_t type) { uint32_t start = this->current_flash_offset; uint32_t end = start + length + 1; if (end > RP2040_FLASH_STORAGE_SIZE) { return {}; } - auto *pref = new RP2040PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + auto *pref = new RP2PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->offset = start; pref->type = type; this->current_flash_offset = end; return ESPPreferenceObject(pref); } -bool RP2040Preferences::sync() { +bool RP2Preferences::sync() { if (!s_flash_dirty) return true; if (s_prevent_write) @@ -121,7 +121,7 @@ bool RP2040Preferences::sync() { return true; } -bool RP2040Preferences::reset() { +bool RP2Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); { InterruptLock lock; @@ -133,9 +133,9 @@ bool RP2040Preferences::reset() { return true; } -static RP2040Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static RP2Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -RP2040Preferences *get_preferences() { return &s_preferences; } +RP2Preferences *get_preferences() { return &s_preferences; } void setup_preferences() { s_preferences.setup(); @@ -143,10 +143,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.h b/esphome/components/rp2/preferences.h similarity index 59% rename from esphome/components/rp2040/preferences.h rename to esphome/components/rp2/preferences.h index eb8c3e5f64f..95f72638830 100644 --- a/esphome/components/rp2040/preferences.h +++ b/esphome/components/rp2/preferences.h @@ -1,14 +1,14 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/preference_backend.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040Preferences final : public PreferencesMixin { +class RP2Preferences final : public PreferencesMixin { public: - using PreferencesMixin::make_preference; - RP2040Preferences(); + using PreferencesMixin::make_preference; + RP2Preferences(); void setup(); ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); @@ -26,8 +26,8 @@ class RP2040Preferences final : public PreferencesMixin { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -DECLARE_PREFERENCE_ALIASES(esphome::rp2040::RP2040Preferences) +DECLARE_PREFERENCE_ALIASES(esphome::rp2::RP2Preferences) -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp similarity index 94% rename from esphome/components/rp2040/printf_stubs.cpp rename to esphome/components/rp2/printf_stubs.cpp index c2174a1dece..bf03565f309 100644 --- a/esphome/components/rp2040/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -13,12 +13,12 @@ * Saves ~8.9 KB of flash. */ -#if defined(USE_RP2040) && !defined(USE_FULL_PRINTF) +#if defined(USE_RP2) && !defined(USE_FULL_PRINTF) #include #include #include -namespace esphome::rp2040 {} +namespace esphome::rp2 {} static constexpr size_t PRINTF_BUFFER_SIZE = 512; @@ -71,4 +71,4 @@ int __wrap_fprintf(FILE *stream, const char *fmt, ...) { } // extern "C" // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -#endif // USE_RP2040 && !USE_FULL_PRINTF +#endif // USE_RP2 && !USE_FULL_PRINTF diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp deleted file mode 100644 index 11f23ccfef0..00000000000 --- a/esphome/components/rp2040/core.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#ifdef USE_RP2040 - -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// rp2040 — there is no extra component bootstrap to keep here. - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040/preference_backend.h b/esphome/components/rp2040/preference_backend.h deleted file mode 100644 index 790ee8831de..00000000000 --- a/esphome/components/rp2040/preference_backend.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_RP2040 - -#include -#include - -namespace esphome::rp2040 { - -class RP2040PreferenceBackend final { - public: - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); - - size_t offset = 0; - uint32_t type = 0; -}; - -class RP2040Preferences; -RP2040Preferences *get_preferences(); - -} // namespace esphome::rp2040 - -namespace esphome { -using PreferenceBackend = rp2040::RP2040PreferenceBackend; -} // namespace esphome - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 648f22691c4..ac012b5e856 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID from esphome.types import ConfigType -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 8afba6ba1d4..b9c0a9c2578 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -1,6 +1,6 @@ #include "led_strip.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index aaa5b0842d6..b74dd141084 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -128,4 +128,4 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { } // namespace esphome::rp2040_pio_led_strip -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 274f059bd5c..b3f816102a5 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg -from esphome.components import light, rp2040 +from esphome.components import light, rp2 import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -130,9 +130,9 @@ def time_to_cycles(time_us): CONF_PIO = "pio" -AUTO_LOAD = ["rp2040_pio"] +AUTO_LOAD = ["rp2_pio"] CODEOWNERS = ["@Papa-DMan"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pio_led_strip_ns = cg.esphome_ns.namespace("rp2040_pio_led_strip") RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( @@ -250,7 +250,7 @@ async def to_code(config): if chipset := config.get(CONF_CHIPSET): cg.add(var.set_chipset(chipset)) _LOGGER.info("Generating PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( @@ -265,7 +265,7 @@ async def to_code(config): else: cg.add(var.set_chipset(Chipset.CHIPSET_CUSTOM)) _LOGGER.info("Generating custom PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index ad37926954e..a2fda58c9e6 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -5,7 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index c9b9e6739d1..270cc335519 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "rp2040_pwm.h" #include "esphome/core/defines.h" diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 49980a7d766..8263113168d 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/components/output/float_output.h" #include "esphome/core/automation.h" @@ -54,4 +54,4 @@ template class SetFrequencyAction final : public Action { } // namespace esphome::rp2040_pwm -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio/__init__.py b/esphome/components/rp2_pio/__init__.py similarity index 98% rename from esphome/components/rp2040_pio/__init__.py rename to esphome/components/rp2_pio/__init__.py index eecfedaa759..9046d2ae6ba 100644 --- a/esphome/components/rp2040_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -3,7 +3,7 @@ import platform import esphome.codegen as cg import esphome.config_validation as cv -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] PIOASM_REPO_VERSION = "1.5.0-b" diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 079665c9596..136d0f1d589 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,7 +1,7 @@ #include "sha256.h" // Only compile SHA256 implementation on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" #include @@ -76,7 +76,7 @@ void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this- void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index d10d418c7a8..26afe9e33e7 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -3,7 +3,7 @@ #include "esphome/core/defines.h" // Only define SHA256 on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include #include @@ -25,7 +25,7 @@ #elif defined(USE_LIBRETINY) #define USE_SHA256_MBEDTLS #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) #include #elif defined(USE_HOST) #include @@ -70,7 +70,7 @@ class SHA256 final : public esphome::HashBase { // The mbedtls context for ESP32-S3 hardware SHA requires proper alignment and stack frame constraints. // See class documentation above for critical requirements. mbedtls_sha256_context ctx_{}; -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) br_sha256_context ctx_{}; bool calculated_{false}; #elif defined(USE_HOST) diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 69a2436d3d2..7d592f8ef80 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -98,7 +98,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 38d787c20a5..cd002d9eb00 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.Schema( CONF_IMPLEMENTATION, esp8266=IMPLEMENTATION_LWIP_TCP, esp32=IMPLEMENTATION_BSD_SOCKETS, - rp2040=IMPLEMENTATION_LWIP_TCP, + rp2=IMPLEMENTATION_LWIP_TCP, bk72xx=IMPLEMENTATION_LWIP_SOCKETS, ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index f9b652f14a7..528d201799b 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -104,7 +104,7 @@ struct iovec { size_t iov_len; }; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // arduino-esp8266 declares a global vars called INADDR_NONE/ANY which are invalid with the define #ifdef INADDR_ANY #undef INADDR_ANY diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index c6692b01654..4fcec553fab 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -17,7 +17,7 @@ extern "C" void esphome_wake_ota_component_any_context(); #ifdef USE_ESP8266 #include // For esp_schedule() -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #include // For __sev(), __wfe() #include // For add_alarm_in_ms(), cancel_alarm() #endif diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d1961cec59c..608adc75144 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -35,7 +35,7 @@ from esphome.const import ( KEY_VARIANT, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -54,7 +54,7 @@ SPIMode = spi_ns.enum("SPIMode") PLATFORM_SPI_CLOCKS = { PLATFORM_ESP8266: 40e6, PLATFORM_ESP32: 80e6, - PLATFORM_RP2040: 62.5e6, + PLATFORM_RP2: 62.5e6, } MAX_DATA_RATE_ERROR = 0.05 # Max allowable actual data rate difference from requested @@ -179,7 +179,7 @@ def get_hw_interface_list(): ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: return [["spi"], ["spi1"]] return [] @@ -247,7 +247,7 @@ def validate_hw_pins(spi, index=-1): if target_platform == PLATFORM_ESP32: return clk_pin_no >= 0 - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: if index == -1: matches = list( filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS) @@ -323,7 +323,7 @@ def get_spi_interface(index): # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks return ["SPI2_HOST", "SPI3_HOST"][index] # Arduino code follows - if platform == PLATFORM_RP2040: + if platform == PLATFORM_RP2: return ["&SPI", "&SPI1"][index] if index == 0: return "&SPI" @@ -349,7 +349,7 @@ SPI_SINGLE_SCHEMA = cv.All( } ), cv.has_at_least_one_key(CONF_MISO_PIN, CONF_MOSI_PIN), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2]), ) @@ -500,7 +500,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index cada29b0d7d..c038426f610 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -17,7 +17,7 @@ using SPIInterface = spi_host_device_t; #include -#ifdef USE_RP2040 +#ifdef USE_RP2 using SPIInterface = SPIClassRP2040 *; #else using SPIInterface = SPIClass *; diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 4267fe63ced..a3e09d28001 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -11,7 +11,7 @@ class SPIDelegateHw : public SPIDelegate { : SPIDelegate(data_rate, bit_order, mode, cs_pin), channel_(channel) {} void begin_transaction() override { -#ifdef USE_RP2040 +#ifdef USE_RP2 SPISettings const settings(this->data_rate_, static_cast(this->bit_order_), this->mode_); #elif defined(ESP8266) // Arduino ESP8266 library has mangled values for SPI modes :-( @@ -41,7 +41,7 @@ class SPIDelegateHw : public SPIDelegate { this->channel_->transfer(*ptr); return; } -#ifdef USE_RP2040 +#ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be @@ -75,7 +75,7 @@ class SPIBusHw : public SPIBus { #ifdef USE_ESP32 channel->begin(Utility::get_pin_no(clk), Utility::get_pin_no(sdi), Utility::get_pin_no(sdo), -1); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 if (Utility::get_pin_no(sdi) != -1) channel->setRX(Utility::get_pin_no(sdi)); if (Utility::get_pin_no(sdo) != -1) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4e623942ac5..6a52348ae9b 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -10,7 +10,7 @@ #ifdef USE_ESP8266 #include "sys/time.h" #endif -#if defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_RP2) || defined(USE_ZEPHYR) #include #endif #include diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 4ea32e26a31..7e3701bb073 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -49,7 +49,7 @@ IDFUARTComponent = uart_ns.class_("IDFUARTComponent", UARTComponent, cg.Componen ESP8266UartComponent = uart_ns.class_( "ESP8266UartComponent", UARTComponent, cg.Component ) -RP2040UartComponent = uart_ns.class_("RP2040UartComponent", UARTComponent, cg.Component) +RP2UartComponent = uart_ns.class_("RP2UartComponent", UARTComponent, cg.Component) LibreTinyUARTComponent = uart_ns.class_( "LibreTinyUARTComponent", UARTComponent, cg.Component ) @@ -59,7 +59,7 @@ HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Compon NATIVE_UART_CLASSES = ( str(IDFUARTComponent), str(ESP8266UartComponent), - str(RP2040UartComponent), + str(RP2UartComponent), str(LibreTinyUARTComponent), ) @@ -157,8 +157,8 @@ def _uart_declare_type(value): return cv.declare_id(ESP8266UartComponent)(value) if CORE.is_esp32: return cv.declare_id(IDFUARTComponent)(value) - if CORE.is_rp2040: - return cv.declare_id(RP2040UartComponent)(value) + if CORE.is_rp2: + return cv.declare_id(RP2UartComponent)(value) if CORE.is_libretiny: return cv.declare_id(LibreTinyUARTComponent)(value) if CORE.is_host: @@ -529,7 +529,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "uart_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "uart_component_host.cpp": {PlatformFramework.HOST_NATIVE}, - "uart_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "uart_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "uart_component_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2.cpp similarity index 91% rename from esphome/components/uart/uart_component_rp2040.cpp rename to esphome/components/uart/uart_component_rp2.cpp index 1aaf98dc84b..9cc3009a223 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -1,5 +1,5 @@ -#ifdef USE_RP2040 -#include "uart_component_rp2040.h" +#ifdef USE_RP2 +#include "uart_component_rp2.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -13,9 +13,9 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2040"; +static const char *const TAG = "uart.arduino_rp2"; -uint16_t RP2040UartComponent::get_config() { +uint16_t RP2UartComponent::get_config() { uint16_t config = 0; if (this->parity_ == UART_CONFIG_PARITY_NONE) { @@ -50,7 +50,7 @@ uint16_t RP2040UartComponent::get_config() { return config; } -void RP2040UartComponent::setup() { +void RP2UartComponent::setup() { auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -162,7 +162,7 @@ void RP2040UartComponent::setup() { } } -void RP2040UartComponent::dump_config() { +void RP2UartComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus:"); LOG_PIN(" TX Pin: ", tx_pin_); LOG_PIN(" RX Pin: ", rx_pin_); @@ -182,7 +182,7 @@ void RP2040UartComponent::dump_config() { } } -void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { +void RP2UartComponent::write_array(const uint8_t *data, size_t len) { this->serial_->write(data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { @@ -190,13 +190,13 @@ void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { } #endif } -bool RP2040UartComponent::peek_byte(uint8_t *data) { +bool RP2UartComponent::peek_byte(uint8_t *data) { if (!this->check_read_timeout_()) return false; *data = this->serial_->peek(); return true; } -bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { +bool RP2UartComponent::read_array(uint8_t *data, size_t len) { if (!this->check_read_timeout_(len)) return false; this->serial_->readBytes(data, len); @@ -207,12 +207,12 @@ bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { #endif return true; } -size_t RP2040UartComponent::available() { return this->serial_->available(); } -UARTFlushResult RP2040UartComponent::flush() { +size_t RP2UartComponent::available() { return this->serial_->available(); } +UARTFlushResult RP2UartComponent::flush() { ESP_LOGVV(TAG, " Flushing"); this->serial_->flush(); return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2.h similarity index 88% rename from esphome/components/uart/uart_component_rp2040.h rename to esphome/components/uart/uart_component_rp2.h index b16d8b12d95..734bc6022ea 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent final : public UARTComponent, public Component { +class RP2UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; @@ -40,4 +40,4 @@ class RP2040UartComponent final : public UARTComponent, public Component { }; } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index b09e87e8110..e1a4e4f4b09 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -8,7 +8,7 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return [] return ["socket"] diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index b05d7d4f6d9..2063faeb91e 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -9,7 +9,7 @@ #include "esp_idf_version.h" #include "esp_task_wdt.h" #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "hardware/watchdog.h" #include "pico/stdlib.h" #endif @@ -53,7 +53,7 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { esp_task_wdt_reconfigure(&wdt_config); #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 watchdog_enable(timeout_ms, true); #endif } @@ -65,7 +65,7 @@ uint32_t WatchdogManager::get_timeout_() { timeout_ms = (uint32_t) CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 timeout_ms = watchdog_get_count() / 1000; #endif diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 788bedec349..f4e9eae7630 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -227,7 +227,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index b587841dfd1..fc575d1c06f 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -63,7 +63,7 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) if CORE.is_libretiny: CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) - if CORE.is_rp2040: + if CORE.is_rp2: # Ignore bundled AsyncTCP libraries - we use RPAsyncTCP from async_tcp component CORE.add_platformio_option( "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index abce1fd5c05..af600647c1f 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -143,8 +143,8 @@ def has_native_wifi( """ if platform == Platform.ESP32: return variant_has_wifi(variant) if variant else True - if platform == Platform.RP2040: - from esphome.components.rp2040 import board_id_has_wifi + if platform == Platform.RP2: + from esphome.components.rp2 import board_id_has_wifi return board_id_has_wifi(board) if board else True return platform in _WIFI_FIRST_PLATFORMS @@ -301,7 +301,7 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) - if CONF_MANUAL_IP in config and CORE.is_rp2040: + if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " "The AP uses the default IP 192.168.4.1" @@ -324,8 +324,8 @@ def validate_variant(_): variant = get_esp32_variant() if variant in NO_WIFI_VARIANTS and "esp32_hosted" not in fv.full_config.get(): raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") - if CORE.is_rp2040: - from esphome.components.rp2040 import board_has_wifi, get_board + if CORE.is_rp2: + from esphome.components.rp2 import board_has_wifi, get_board if not board_has_wifi(): raise cv.Invalid( @@ -369,7 +369,7 @@ def _consume_wifi_sockets(config: ConfigType) -> ConfigType: DHCP/DNS). On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — DHCP/DNS use raw udp_new() which bypasses it entirely. """ - if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2040): + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2): return config from esphome.components import socket @@ -473,7 +473,7 @@ CONFIG_SCHEMA = cv.All( CONF_POWER_SAVE_MODE, esp8266="none", esp32="light", - rp2040="light", + rp2="light", bk72xx="none", rtl87xx="none", ln882x="light", @@ -676,7 +676,7 @@ async def to_code(config): if CONF_PHY_MODE in config: cg.add_define("USE_WIFI_PHY_MODE") cg.add(var.set_phy_mode(config[CONF_PHY_MODE])) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("WiFi", None) if CORE.is_esp32: @@ -944,7 +944,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, + "wifi_component_pico_w.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 2f6bec6bb26..c951e74358f 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2388,7 +2388,7 @@ void WiFiComponent::clear_roaming_state_() { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c774e3a68ef..0db85c4d758 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -46,7 +46,7 @@ extern "C" { #endif #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 extern "C" { #include "cyw43.h" #include "cyw43_country.h" @@ -181,7 +181,7 @@ static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; // Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) // Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -815,7 +815,7 @@ class WiFiComponent final : public Component { friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 596fd2729b3..1a70f81a2b5 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -1,7 +1,7 @@ #include "wifi_component.h" #ifdef USE_WIFI -#ifdef USE_RP2040 +#ifdef USE_RP2 #include diff --git a/esphome/config_validation.py b/esphome/config_validation.py index b77e22a6fb4..45fd94fd1a1 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,7 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, @@ -859,7 +859,38 @@ def only_with_framework( only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_nrf52 = only_on(PLATFORM_NRF52) -only_on_rp2040 = only_on(PLATFORM_RP2040) +only_on_rp2 = only_on(PLATFORM_RP2) + +# CORE.data key for the "deprecation warning already fired this run" flag. +# Deduped via CORE.data (cleared between runs) to match the framework-alias +# pattern; one warning per `esphome config|compile|run` invocation is enough. +_ONLY_ON_RP2040_DEPRECATED_KEY = "_cv_only_on_rp2040_deprecated_warned" + + +def only_on_rp2040(obj): + """Deprecated — kept as a back-compat shim for external custom components. + + Pre-RP2350, this was the family check for the RP2 platform; with RP2350 + landing under the same target platform, the variant axis is now exposed + by the rp2 component itself. New code should use one of: + + * :func:`only_on_rp2` — family-level gate (matches the esp32 pattern; + same semantics as the pre-RP2350 ``only_on_rp2040``). + * ``rp2.only_on_variant(supported=[VARIANT_RP2040])`` — variant-level + gate, rejects RP2350 boards on the rp2 platform. + + Scheduled for removal in 2027.7.0. + """ + if not CORE.data.get(_ONLY_ON_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "cv.only_on_rp2040 is deprecated; use cv.only_on_rp2 for the " + "family gate, or rp2.only_on_variant(supported=[VARIANT_RP2040]) " + "for the variant gate. Removed in 2027.7.0." + ) + CORE.data[_ONLY_ON_RP2040_DEPRECATED_KEY] = True + return only_on_rp2(obj) + + only_with_arduino = only_with_framework(Framework.ARDUINO) @@ -1990,7 +2021,24 @@ def _get_default_key(*args): class SplitDefault(Optional): - """Mark this key to have a split default for ESP8266/ESP32.""" + """Mark this key to have a split default per target platform / variant / framework. + + Defaults are passed as kwargs keyed on the platform identifier; the most + specific match wins. Lookup order (first hit wins): + + 1. ``__`` — e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino`` + 2. ``_`` — e.g. ``esp32_c3``, ``rp2_2040`` + 3. ``_`` — e.g. ``esp32_arduino``, + ``rp2_arduino`` + 4. ```` — e.g. ``esp32``, ``rp2`` + + For ESP32 the variant strips the ``ESP32`` prefix from + :data:`esp32.VARIANT_*` constants (``ESP32C3`` → ``c3``). For RP2 the + variant strips just ``RP`` (``RP2040`` → ``2040``, ``RP2350`` → ``2350``) + so kwargs read naturally — `rp2_2040=...` is the override for the + Pico / Pico W and `rp2_2350=...` is the override for the Pico 2. + """ def __init__(self, key, **kwargs): super().__init__(key) @@ -2012,6 +2060,22 @@ class SplitDefault(Optional): keys += _get_default_key(variant, framework) keys += _get_default_key(variant) keys += _get_default_key(framework) + elif CORE.is_rp2: + # Strip the "RP" prefix to leave the chip number, mirroring + # the ESP32 "platform stripped from variant" convention so + # kwargs stay short (``rp2_2040`` rather than ``rp2_rp2040``). + # Variant lookup is defensive: validators may run before the + # rp2 component's ``set_core_data`` (or in tests that wire a + # partial ``CORE.data``); in that case we just skip the + # variant-specific keys and fall through to the base + # platform/framework defaults. + raw_variant = CORE.data.get("rp2", {}).get("variant") + framework = CORE.target_framework + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys += _get_default_key(variant, framework) + keys += _get_default_key(variant) + keys += _get_default_key(framework) keys += _get_default_key() for key in keys: if self._defaults.get(key) is not None: @@ -2443,18 +2507,58 @@ def require_framework_version( extra_message=None, **kwargs, ): + """Constrain the configured framework version per target platform / variant. + + Kwargs are keyed by ``_`` (e.g. ``esp32_arduino``, + ``rp2_arduino``) with optional variant-specific overrides keyed by + ``__`` (e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino``, ``rp2_2350_arduino``). Variant overrides win when + the configured variant matches; otherwise the base platform key is used. + + Special cases: ``host`` (with host framework) and ``esp_idf`` (any ESP32 + on ESP-IDF) bypass variant lookup. + """ + def validator(value): core_data = CORE.data[KEY_CORE] framework = core_data[KEY_TARGET_FRAMEWORK] + keys_to_try: list[str] = [] if CORE.is_host and framework == "host": - key = "host" + keys_to_try.append("host") elif framework == "esp-idf": - key = "esp_idf" + keys_to_try.append("esp_idf") else: - key = CORE.target_platform + "_" + framework + # Try variant-specific key first (mirrors the SplitDefault + # precedence). ESP32 strips its platform prefix from variant + # constants; RP2 strips just ``RP`` to keep chip-number kwargs + # (``rp2_2040``, ``rp2_2350``). + if CORE.is_esp32: + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant - if key not in kwargs: + # Guard against tests that wire CORE.data without an + # esp32 variant block; same defensive intent as the rp2 + # branch below. + try: + variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() + except (KeyError, AttributeError): + variant = "" + if variant: + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + elif CORE.is_rp2: + # Defensive lookup — see the matching block in + # ``SplitDefault.default``: the rp2 component's + # ``set_core_data`` may not have populated + # ``CORE.data["rp2"]["variant"]`` yet (validators run + # during schema validation, before code-gen). + raw_variant = CORE.data.get("rp2", {}).get("variant") + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + keys_to_try.append(f"{CORE.target_platform}_{framework}") + + key = next((k for k in keys_to_try if k in kwargs), None) + if key is None: msg = f"This feature is incompatible with {CORE.target_platform.upper()} using {framework} framework" if extra_message: msg += f". {extra_message}" diff --git a/esphome/const.py b/esphome/const.py index 24bb4ea31f5..16d11d3a18a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -33,7 +33,12 @@ class Platform(StrEnum): LIBRETINY_OLDSTYLE = "libretiny" LN882X = "ln882x" NRF52 = "nrf52" - RP2040 = "rp2040" + RP2 = "rp2" # canonical name for the RP2 family (RP2040, RP2350, …) + # Deprecated: use Platform.RP2 instead. Python enum aliasing makes this + # the same member as RP2 (same string value), so ``Platform.RP2040`` and + # ``Platform.RP2`` remain interchangeable for external custom components. + # Scheduled for removal in 2027.7.0. + RP2040 = "rp2" RTL87XX = "rtl87xx" @@ -86,6 +91,9 @@ class PlatformFramework(Enum): # Arduino framework platforms ESP8266_ARDUINO = (Platform.ESP8266, Framework.ARDUINO) + RP2_ARDUINO = (Platform.RP2, Framework.ARDUINO) + # Deprecated: use PlatformFramework.RP2_ARDUINO instead. Kept as an + # alias for backwards compatibility; scheduled for removal in 2027.7.0. RP2040_ARDUINO = (Platform.RP2040, Framework.ARDUINO) BK72XX_ARDUINO = (Platform.BK72XX, Framework.ARDUINO) RTL87XX_ARDUINO = (Platform.RTL87XX, Framework.ARDUINO) @@ -106,6 +114,9 @@ PLATFORM_HOST = Platform.HOST PLATFORM_LIBRETINY_OLDSTYLE = Platform.LIBRETINY_OLDSTYLE PLATFORM_LN882X = Platform.LN882X PLATFORM_NRF52 = Platform.NRF52 +PLATFORM_RP2 = Platform.RP2 +# Deprecated: use PLATFORM_RP2 instead. Kept as a back-compat alias; +# scheduled for removal in 2027.7.0. PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89ce27a8b99..803ddba6b71 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -25,7 +25,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, Toolchain, ) @@ -52,6 +52,11 @@ _LOGGER = logging.getLogger(__name__) # Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# CORE.data key for the "is_rp2040 deprecation warning already fired this +# run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared +# between runs so each fresh invocation warns once. +_IS_RP2040_DEPRECATED_KEY = "_core_is_rp2040_deprecated_warned" + class EsphomeError(Exception): """General ESPHome exception occurred.""" @@ -830,9 +835,38 @@ class EsphomeCore: def is_esp32(self): return self.target_platform == PLATFORM_ESP32 + @property + def is_rp2(self): + """Return True if the target platform is the RP2 chip family. + + Canonical umbrella check covering RP2040, RP2350, and any future + RP2-series chip. Mirrors :attr:`is_esp32` for the ESP32 family. + For variant-specific gating (RP2040 vs RP2350), use + ``rp2.get_rp2040_variant()`` or ``rp2.only_on_variant(...)`` from + the rp2 component — variant detection doesn't belong on ``CORE``. + """ + return self.target_platform == PLATFORM_RP2 + @property def is_rp2040(self): - return self.target_platform == PLATFORM_RP2040 + """Deprecated: use :attr:`is_rp2` for the family check, or + ``rp2.get_rp2040_variant() == rp2.VARIANT_RP2040`` for the + variant-specific check. Kept as an alias since pre-RP2350 + callers used it as a family check, identical to ``is_rp2``. + + Scheduled for removal in 2027.7.0. Logs a one-shot deprecation + warning per run (deduped via ``self.data`` so repeated reads in + the same invocation don't spam) to match the parallel + ``cv.only_on_rp2040`` shim. + """ + if not self.data.get(_IS_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "CORE.is_rp2040 is deprecated; use CORE.is_rp2 for the family " + "gate, or rp2.get_rp2040_variant() == rp2.VARIANT_RP2040 for " + "the variant-specific check. Removed in 2027.7.0." + ) + self.data[_IS_RP2040_DEPRECATED_KEY] = True + return self.is_rp2 @property def is_bk72xx(self): diff --git a/esphome/core/config.py b/esphome/core/config.py index ebad5cf1656..5b95ac3a508 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -868,8 +868,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_esp8266.cpp": { PlatformFramework.ESP8266_ARDUINO, }, - "wake/wake_rp2040.cpp": { - PlatformFramework.RP2040_ARDUINO, + "wake/wake_rp2.cpp": { + PlatformFramework.RP2_ARDUINO, }, "wake/wake_host.cpp": { PlatformFramework.HOST_NATIVE, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 987e2d7a2a8..3e8b0829c51 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -19,13 +19,13 @@ // Threading model for static analysis. Match what the real codegen picks per // platform (see esphome/components//__init__.py ThreadModel.*): -// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_ESP8266 / USE_RP2 / USE_NRF52 → SINGLE // USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS // everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS // Without this the clang-tidy envs end up with USE_ // + MULTI_ATOMICS simultaneously, a combination that can never occur in a // real build. -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_NRF52) #define ESPHOME_THREAD_SINGLE #elif defined(USE_BK72XX) #define ESPHOME_THREAD_MULTI_NO_ATOMICS @@ -227,7 +227,7 @@ #endif // Platforms with native 64-bit time sources (no rollover tracking needed) -#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2) #define USE_NATIVE_64BIT_TIME #endif @@ -405,9 +405,12 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #endif -#ifdef USE_RP2040 +// USE_RP2 is the canonical platform define for the RP2 chip family. The +// rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias +// for external custom components that may still test for it. +#ifdef USE_RP2 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) -#define USE_RP2040_CRASH_HANDLER +#define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC diff --git a/esphome/core/hal.h b/esphome/core/hal.h index b44a422836c..4c5a19c6d1b 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -19,8 +19,8 @@ #include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/hal.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/hal.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/hal.h" #elif defined(USE_HOST) #include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a2120196280..f39b5aa4d0f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -33,7 +33,7 @@ #include #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #endif @@ -1895,7 +1895,7 @@ class Mutex { Mutex(const Mutex &) = delete; Mutex &operator=(const Mutex &) = delete; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // Single-threaded platforms: inline no-ops so the compiler eliminates all call overhead. Mutex() = default; ~Mutex() = default; @@ -1964,7 +1964,7 @@ class InterruptLock { ~InterruptLock(); protected: -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) uint32_t state_; #endif }; @@ -1982,7 +1982,7 @@ class LwIPLock { LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; -#if defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_RP2) // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp LwIPLock(); ~LwIPLock(); @@ -2132,7 +2132,7 @@ template class RAMAllocator { auto max_external = this->flags_ & ALLOC_EXTERNAL ? heap_caps_get_free_size(MALLOC_CAP_8BIT | MALLOC_CAP_SPIRAM) : 0; return max_internal + max_external; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) return ::rp2040.getFreeHeap(); #elif defined(USE_LIBRETINY) return lt_heap_get_free(); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 431de205af9..34bf84409d7 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -12,8 +12,8 @@ #include "esphome/components/esp32/preference_backend.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preference_backend.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preference_backend.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preference_backend.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preference_backend.h" #elif defined(USE_HOST) @@ -24,7 +24,7 @@ namespace esphome { -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. struct PreferenceBackend { diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index 64a0a927e67..1efce5af515 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -9,8 +9,8 @@ #include "esphome/components/esp32/preferences.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preferences.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preferences.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preferences.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preferences.h" #elif defined(USE_HOST) diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5a5d27ceff9..a48e52fb734 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -18,7 +18,7 @@ namespace esphome { // === Wake flag for ESP8266/RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern volatile bool g_main_loop_woke; #endif @@ -65,8 +65,8 @@ __attribute__((always_inline)) inline bool wake_request_take() { #include "esphome/core/wake/wake_freertos.h" #elif defined(USE_ESP8266) #include "esphome/core/wake/wake_esp8266.h" -#elif defined(USE_RP2040) -#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_RP2) +#include "esphome/core/wake/wake_rp2.h" #elif defined(USE_HOST) #include "esphome/core/wake/wake_host.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2.cpp similarity index 97% rename from esphome/core/wake/wake_rp2040.cpp rename to esphome/core/wake/wake_rp2.cpp index bdcbb1ad00c..101c87c8183 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -1,6 +1,6 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" #include "esphome/core/wake.h" @@ -59,4 +59,4 @@ void wakeable_delay(uint32_t ms) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2.h similarity index 88% rename from esphome/core/wake/wake_rp2040.h rename to esphome/core/wake/wake_rp2.h index ea1242f535c..715e5aca0c8 100644 --- a/esphome/core/wake/wake_rp2040.h +++ b/esphome/core/wake/wake_rp2.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -21,11 +21,11 @@ inline void wake_loop_any_context() { inline void wake_loop_threadsafe() { wake_loop_any_context(); } -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2.cpp. namespace internal { void wakeable_delay(uint32_t ms); } // namespace internal } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 9d662df8f89..6376e573c46 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -133,7 +133,7 @@ class StorageJSON: self.no_mdns = no_mdns # The framework used to compile the firmware self.framework = framework - # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. + # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain diff --git a/esphome/wizard.py b/esphome/wizard.py index f83342cc6a5..f7706928e92 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -75,8 +75,8 @@ esp32: type: esp-idf """ -RP2040_CONFIG = """ -rp2040: +RP2_CONFIG = """ +rp2: board: {board} """ @@ -98,7 +98,7 @@ rtl87xx: HARDWARE_BASE_CONFIGS = { "ESP8266": ESP8266_CONFIG, "ESP32": ESP32_CONFIG, - "RP2040": RP2040_CONFIG, + "RP2": RP2_CONFIG, "BK72XX": BK72XX_CONFIG, "LN882X": LN882X_CONFIG, "RTL87XX": RTL87XX_CONFIG, @@ -113,7 +113,7 @@ class WizardFileKwargs(TypedDict): """Keyword arguments for wizard_file function.""" name: str - platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"] + platform: Literal["ESP8266", "ESP32", "RP2", "BK72XX", "LN882X", "RTL87XX"] board: str ssid: NotRequired[str] psk: NotRequired[str] @@ -213,7 +213,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards name = kwargs["name"] @@ -235,8 +235,8 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: platform = "ESP8266" elif board in esp32_boards.BOARDS: platform = "ESP32" - elif board in rp2040_boards.BOARDS: - platform = "RP2040" + elif board in rp2_boards.BOARDS: + platform = "RP2" elif board in bk72xx_boards.BOARDS: platform = "BK72XX" elif board in ln882x_boards.BOARDS: @@ -301,7 +301,7 @@ def wizard(path: Path) -> int: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards if path.suffix not in (".yaml", ".yml"): @@ -373,7 +373,7 @@ def wizard(path: Path) -> int: "firmwares for it." ) - wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"] + wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2"] safe_print( "Please choose one of the supported microcontrollers " "(Use ESP8266 for Sonoff devices)." @@ -405,7 +405,7 @@ def wizard(path: Path) -> int: board_link = ( "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) - elif platform == "RP2040": + elif platform == "RP2": board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" @@ -421,27 +421,21 @@ def wizard(path: Path) -> int: safe_print(f"(Type {color(AnsiFore.GREEN, 'esp01_1m')} for Sonoff devices)") safe_print() # Don't sleep because user needs to copy link - if platform == "ESP32": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcu-32s")}".') - boards_list = esp32_boards.BOARDS.items() - elif platform == "ESP8266": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcuv2")}".') - boards_list = esp8266_boards.BOARDS.items() - elif platform == "BK72XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".') - boards_list = bk72xx_boards.BOARDS.items() - elif platform == "LN882X": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".') - boards_list = ln882x_boards.BOARDS.items() - elif platform == "RTL87XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".') - boards_list = rtl87xx_boards.BOARDS.items() - elif platform == "RP2040": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "rpipicow")}".') - boards_list = rp2040_boards.BOARDS.items() - - else: - raise NotImplementedError("Unknown platform!") + # Platform-to-(example board, boards module) lookup. Dict-driven so the + # set of supported platforms has a single source of truth and the elif + # chain — which left the last entry's "False" branch structurally + # unreachable in tests — is gone. + example_boards = { + "ESP32": ("nodemcu-32s", esp32_boards), + "ESP8266": ("nodemcuv2", esp8266_boards), + "BK72XX": ("cb2s", bk72xx_boards), + "LN882X": ("wl2s", ln882x_boards), + "RTL87XX": ("wr3", rtl87xx_boards), + "RP2": ("rpipicow", rp2_boards), + } + example, boards_module = example_boards[platform] + safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, example)}".') + boards_list = boards_module.BOARDS.items() boards = [] safe_print("Options:") diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 974957245a7..bc97a0d6035 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -785,6 +785,29 @@ def build_schema(): # bundle core inside esphome data["esphome"]["core"] = data.pop("core")["core"] + # Surface deprecated component aliases (declared via ``ALIASES = [...]`` + # on the canonical component) so language servers / dashboard + # autocomplete still accept legacy top-level keys instead of flagging + # them as unknown. Each alias gets its own bundle that mirrors the + # canonical schema; ``alias_of`` and the optional ``removal_version`` + # metadata let consumers render a deprecation hint and point users at + # the canonical name. Without this, configs migrated only at runtime + # (via the ``_resolve_component_aliases`` pre-pass) would still light + # up as errors in the editor. + for domain, manifest in components.items(): + aliases = manifest.aliases + if not aliases or domain not in data: + continue + canonical_bundle = data[domain].get(domain) + if canonical_bundle is None: + continue + for alias in aliases: + alias_entry = dict(canonical_bundle) + alias_entry["alias_of"] = domain + if manifest.alias_removal_version is not None: + alias_entry["removal_version"] = manifest.alias_removal_version + data[alias] = {alias: alias_entry} + if GENERATED_ID_TYPES: print( "Unconsumed id_type matchers:", diff --git a/script/ci-custom.py b/script/ci-custom.py index 75f4d71ba43..4b16734ebe3 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -621,6 +621,9 @@ def convert_path_to_relative(abspath, current): "esphome/components/web_server/__init__.py", # const.py has absolute import in docstring example for external components "esphome/components/esp8266/const.py", + # rp2040/__init__.py is the deprecation shim that documents the canonical + # rp2 module path and its own legacy import paths in docstrings/comments. + "esphome/components/rp2040/__init__.py", ], ) def lint_relative_py_import(fname: Path, line, col, content): @@ -650,13 +653,13 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/async_tcp/async_tcp.h", "esphome/components/esp32/core.cpp", "esphome/components/esp8266/core.cpp", - "esphome/components/rp2040/core.cpp", + "esphome/components/rp2/core.cpp", "esphome/components/libretiny/core.cpp", "esphome/components/host/core.cpp", "esphome/components/zephyr/core.cpp", "esphome/components/esp32/helpers.cpp", "esphome/components/esp8266/helpers.cpp", - "esphome/components/rp2040/helpers.cpp", + "esphome/components/rp2/helpers.cpp", "esphome/components/libretiny/helpers.cpp", "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 756f3884b82..061485c76c2 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -160,7 +160,8 @@ class Platform(StrEnum): BK72XX_ARD = "bk72xx-ard" # LibreTiny BK7231N RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x LN882X_ARD = "ln882x-ard" # LibreTiny LN882x - RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico + RP2040_ARD = "rp2040-ard" # RP2 family, RP2040 chip (Pico / Pico W) + RP2350_ARD = "rp2350-ard" # RP2 family, RP2350 chip (Pico 2 / Pico 2 W) NRF52_ZEPHYR = "nrf52-adafruit" # Nordic nRF52 (Zephyr) @@ -190,7 +191,8 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.BK72XX_ARD, # LibreTiny BK7231N Platform.RTL87XX_ARD, # LibreTiny RTL8720x Platform.LN882X_ARD, # LibreTiny LN882x - Platform.RP2040_ARD, # Raspberry Pi Pico + Platform.RP2040_ARD, # Raspberry Pi Pico (RP2040) + Platform.RP2350_ARD, # Raspberry Pi Pico 2 (RP2350) Platform.NRF52_ZEPHYR, # Nordic nRF52 (Zephyr) ] @@ -859,7 +861,8 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: - *_libretiny.cpp, *_bk72*.* -> BK72XX (LibreTiny) - *_rtl87*.* -> RTL87XX (LibreTiny Realtek) - *_ln882*.* -> LN882X (LibreTiny Lightning) - - *_pico.cpp, *_rp2040.* -> RP2040_ARD + - *_rp2350*.*, *_pico2*.* -> RP2350_ARD (RP2 family, RP2350 chip) + - *_rp2040*.*, *_pico*.* -> RP2040_ARD (RP2 family, RP2040 chip) Args: filename: File path to check @@ -901,8 +904,14 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: if "libretiny" in filename_lower or "bk72" in filename_lower: return Platform.BK72XX_ARD - # RP2040 / Raspberry Pi Pico - if "pico" in filename_lower or "rp2040" in filename_lower: + # RP2 family (Raspberry Pi Pico): explicit chip names only. Family- + # wide files (named ``_rp2.*``) are shared between RP2040 and RP2350 + # and intentionally don't preferentially route to either chip. + # Check the RP2350 patterns first since ``pico2`` substring-matches + # ``pico``. + if "rp2350" in filename_lower or "pico2" in filename_lower: + return Platform.RP2350_ARD + if "rp2040" in filename_lower or "pico" in filename_lower: return Platform.RP2040_ARD # nRF52 / Zephyr diff --git a/script/generate-rp2040-boards.py b/script/generate-rp2-boards.py similarity index 77% rename from script/generate-rp2040-boards.py rename to script/generate-rp2-boards.py index 1b4846fd2b8..94a5cc018a4 100755 --- a/script/generate-rp2040-boards.py +++ b/script/generate-rp2-boards.py @@ -8,14 +8,14 @@ import subprocess import sys import tempfile -from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION -from esphome.components.rp2040.generate_boards import generate +from esphome.components.rp2 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +from esphome.components.rp2.generate_boards import generate from esphome.helpers import write_file_if_changed ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}" root: Path = Path(__file__).parent.parent -boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py" +boards_file_path: Path = root / "esphome" / "components" / "rp2" / "boards.py" def main(check: bool) -> None: @@ -42,10 +42,10 @@ def main(check: bool) -> None: if check: existing_content: str = boards_file_path.read_text(encoding="utf-8") if existing_content != content: - print("esphome/components/rp2040/boards.py is not up to date.") - print("Please run `script/generate-rp2040-boards.py`") + print("esphome/components/rp2/boards.py is not up to date.") + print("Please run `script/generate-rp2-boards.py`") sys.exit(1) - print("esphome/components/rp2040/boards.py is up to date") + print("esphome/components/rp2/boards.py is up to date") elif write_file_if_changed(boards_file_path, content): print("RP2040 boards updated successfully.") diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2350-ard.yaml similarity index 100% rename from tests/components/adc/test.rp2040-pico2-ard.yaml rename to tests/components/adc/test.rp2350-ard.yaml diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2/test.rp2040-ard.yaml similarity index 97% rename from tests/components/rp2040/test.rp2040-ard.yaml rename to tests/components/rp2/test.rp2040-ard.yaml index 09531f914ed..eaa494a01a4 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2040 enable_full_printf: false diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2/test.rp2350-ard.yaml similarity index 90% rename from tests/components/rp2040/test.rp2040-pico2-ard.yaml rename to tests/components/rp2/test.rp2350-ard.yaml index c9d795840d8..84ee39a81e1 100644 --- a/tests/components/rp2040/test.rp2040-pico2-ard.yaml +++ b/tests/components/rp2/test.rp2350-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2350 enable_full_printf: false diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2350-ard.yaml similarity index 100% rename from tests/components/spi/test.rp2040-pico2-ard.yaml rename to tests/components/spi/test.rp2350-ard.yaml diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 2f038155c0d..d018c6dbd05 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2225,15 +2225,33 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "esphome/components/libretiny/wifi_ln882x.cpp", determine_jobs.Platform.LN882X_ARD, ), - # RP2040 / Raspberry Pi Pico detection + # RP2 family detection — explicit chip names only. + # RP2040 chip: _rp2040.*, _pico.* (Pico / Pico W) ("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD), ( - "tests/components/rp2040/test.rp2040-ard.yaml", + "tests/components/rp2/test.rp2040-ard.yaml", determine_jobs.Platform.RP2040_ARD, ), + # RP2350 chip: _rp2350.*, _pico2.* (Pico 2 / Pico 2 W) + ( + "esphome/components/foo/foo_rp2350.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "esphome/components/wifi/wifi_pico2.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "tests/components/rp2/test.rp2350-ard.yaml", + determine_jobs.Platform.RP2350_ARD, + ), + # Family-wide files (_rp2.*) intentionally do NOT get a hint — + # they apply to both RP2040 and RP2350 chips. + ("esphome/components/debug/debug_rp2.cpp", None), + ("esphome/components/logger/logger_rp2.h", None), # nRF52 / Zephyr detection ( "tests/components/logger/test.nrf52-adafruit.yaml", @@ -2280,6 +2298,11 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "pico_i2c", "pico_spi", "rp2040_test_yaml", + "rp2350_cpp", + "pico2_cpp", + "rp2350_test_yaml", + "rp2_family_debug_no_hint", + "rp2_family_logger_h_no_hint", "nrf52_test_yaml", "nrf52_gpio", "zephyr_core", diff --git a/tests/test_build_components/build_components_base.rp2040-ard.yaml b/tests/test_build_components/build_components_base.rp2040-ard.yaml index 4fb8d513336..4d26a38b699 100644 --- a/tests/test_build_components/build_components_base.rp2040-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2040-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040ard friendly_name: $component_name -rp2040: +rp2: board: rpipicow logger: diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml similarity index 97% rename from tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml rename to tests/test_build_components/build_components_base.rp2350-ard.yaml index 0922a5238e8..5df16708621 100644 --- a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name -rp2040: +rp2: board: rpipico2 logger: diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2350-ard.yaml similarity index 100% rename from tests/test_build_components/common/spi/rp2040-pico2-ard.yaml rename to tests/test_build_components/common/spi/rp2350-ard.yaml diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py new file mode 100644 index 00000000000..023d926dc4a --- /dev/null +++ b/tests/unit_tests/components/test_rp2.py @@ -0,0 +1,95 @@ +"""Tests for the ``rp2`` target-platform component. + +``rp2`` is the canonical name for the Raspberry Pi RP-series target +platform. ``rp2040`` is a deprecated alias declared via +``ALIASES = ["rp2040"]`` on the rp2 component — the framework +(see ``esphome/loader.py`` and ``esphome/config.py``) handles both +Python-import aliasing (via a ``sys.meta_path`` finder) and YAML-key +aliasing (via a pre-pass in ``validate_config``), so there is no +hand-rolled shim in ``esphome/components/rp2040/``. + +These tests pin down the canonical board helpers; the alias contract +itself (Python imports, YAML key rename, deprecation warning) is covered +by the framework tests under ``tests/unit_tests/``. +""" + + +def test_board_id_has_wifi_for_known_wifi_board() -> None: + """``rpipicow`` is the canonical Pico W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipicow") is True + + +def test_board_id_has_wifi_for_known_non_wifi_board() -> None: + """Plain ``rpipico`` has no CYW43 → False.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico") is False + + +def test_board_id_has_wifi_for_rp2350_w_variant() -> None: + """``rpipico2w`` is the RP2350 Pico 2 W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico2w") is True + + +def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: + """Unknown ids fail open so a custom board is not rejected. + + The validator falls back to ESPHome's compile-time check; the + helper returning True here means the wizard emits a ``wifi:`` + block and any genuinely-unsupported config trips the existing + "no CYW43" guard at compile time. + """ + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("not-a-real-board-id") is True + + +def test_rp2_declares_rp2040_as_alias() -> None: + """The framework-level deprecation hook is on the ``rp2`` component. + + The legacy ``rp2040:`` YAML key works because the rp2 component + opts in via ``ALIASES``; without this declaration the rename + framework wouldn't route legacy configs. + """ + from esphome.components import rp2 + + assert "rp2040" in rp2.ALIASES + assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" + + +def test_rp2040_python_import_resolves_to_rp2() -> None: + """``from esphome.components import rp2040`` must work for external + custom components and external tooling (device-builder, the dashboard + wizard, etc.) that still import from the legacy module path. + + The ``_AliasFinder`` on ``sys.meta_path`` rewrites the lookup to + the canonical module — both should be the same object. + """ + from esphome.components import ( + rp2, + rp2040, # routed via _AliasFinder + ) + + assert rp2040 is rp2 + + +def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: + """Submodule imports (e.g. ``esphome.components.rp2040.boards``) must + also route to the canonical equivalents — the board-generator script + and the dashboard wizard both rely on this path. + """ + from esphome.components.rp2 import ( + boards as rp2_boards, + generate_boards as rp2_generate, + ) + from esphome.components.rp2040 import ( + boards as rp2040_boards, + generate_boards as rp2040_generate, + ) + + assert rp2040_boards is rp2_boards + assert rp2040_generate is rp2_generate diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py deleted file mode 100644 index 8e726933edd..00000000000 --- a/tests/unit_tests/components/test_rp2040.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for RP2040 component public helpers and variant detection.""" - -import pytest - -from esphome.components.rp2040 import _detect_variant, board_id_has_wifi -from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 -import esphome.config_validation as cv -from esphome.const import CONF_BOARD, CONF_VARIANT - - -def test_board_id_has_wifi_for_known_wifi_board() -> None: - """``rpipicow`` is the canonical Pico W → True.""" - assert board_id_has_wifi("rpipicow") is True - - -def test_board_id_has_wifi_for_known_non_wifi_board() -> None: - """Plain ``rpipico`` has no CYW43 → False.""" - assert board_id_has_wifi("rpipico") is False - - -def test_board_id_has_wifi_for_rp2350_w_variant() -> None: - """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - assert board_id_has_wifi("rpipico2w") is True - - -def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: - """Unknown ids fail open so a custom board is not rejected. - - The validator falls back to ESPHome's compile-time check; the - helper returning True here means the wizard emits a ``wifi:`` - block and any genuinely-unsupported config trips the existing - "no CYW43" guard at compile time. - """ - assert board_id_has_wifi("not-a-real-board-id") is True - - -def test_detect_variant_derives_variant_from_board() -> None: - """Board alone resolves to the matching variant.""" - result = _detect_variant({CONF_BOARD: "rpipicow"}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_derives_variant_from_rp2350_board() -> None: - """An RP2350 board resolves to ``RP2350``.""" - result = _detect_variant({CONF_BOARD: "rpipico2"}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_only_picks_default_board_rp2040() -> None: - """Variant alone picks Pico W as the canonical RP2040 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_only_picks_default_board_rp2350() -> None: - """Variant alone picks Pico 2 W as the canonical RP2350 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2w" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_matching_explicit_variant_passes() -> None: - """Specifying both a board and the matching variant is allowed.""" - result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_mismatched_variant_raises() -> None: - """Board/variant mismatch must be rejected and name the offending board.""" - with pytest.raises( - cv.Invalid, match=r"does not match the selected board 'rpipicow'" - ): - _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) - - -def test_detect_variant_unknown_board_without_variant_raises() -> None: - """Unknown board with no variant tells the user how to recover.""" - with pytest.raises(cv.Invalid, match="please specify the chip variant"): - _detect_variant({CONF_BOARD: "not-a-real-board"}) - - -def test_detect_variant_unknown_board_with_variant_passes() -> None: - """Unknown board + explicit variant is accepted (with a warning).""" - result = _detect_variant( - {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} - ) - assert result[CONF_BOARD] == "not-a-real-board" - assert result[CONF_VARIANT] == VARIANT_RP2040 diff --git a/tests/unit_tests/components/test_rp2040_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py similarity index 98% rename from tests/unit_tests/components/test_rp2040_generate_boards.py rename to tests/unit_tests/components/test_rp2_generate_boards.py index 551e88f6f6f..68bbada59b6 100644 --- a/tests/unit_tests/components/test_rp2040_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -1,4 +1,4 @@ -"""Tests for rp2040 generate_boards.py.""" +"""Tests for rp2 generate_boards.py.""" from __future__ import annotations @@ -8,7 +8,7 @@ import textwrap import pytest -from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 9598c1bdd86..3899b3d8540 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -87,8 +87,8 @@ def test_has_native_wifi_esp32_variant_case_insensitive() -> None: def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" - assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True - assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False + assert has_native_wifi(platform=Platform.RP2, board="rpipicow") is True + assert has_native_wifi(platform=Platform.RP2, board="rpipico") is False def test_has_native_wifi_returns_false_for_nrf52() -> None: @@ -134,7 +134,7 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" - assert has_native_wifi(platform=Platform.RP2040) is True + assert has_native_wifi(platform=Platform.RP2) is True def _wifi_config( diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index ea3a4ecb532..6580564c65a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -39,7 +39,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, TYPE_GIT, @@ -438,7 +438,7 @@ def hex_int__valid(value): ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), - ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), + ("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"), ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), @@ -469,7 +469,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_c3": "11", "esp32_c6": "14", "esp32_h2": "17", - "rp2040": "20", + "rp2": "20", "bk72xx": "21", "rtl87xx": "22", "ln882x": "23", @@ -517,7 +517,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), - ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), + ("arduino", PLATFORM_RP2, "RP2 using arduino framework"), ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), ("host", PLATFORM_HOST, "HOST using host framework"), ], @@ -540,7 +540,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(0, 5, 0), esp32_arduino=cv.Version(0, 5, 0), esp8266_arduino=cv.Version(0, 5, 0), - rp2040_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), bk72xx_arduino=cv.Version(0, 5, 0), host=cv.Version(0, 5, 0), extra_message="test 1", @@ -556,7 +556,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(2, 0, 0), esp32_arduino=cv.Version(2, 0, 0), esp8266_arduino=cv.Version(2, 0, 0), - rp2040_arduino=cv.Version(2, 0, 0), + rp2_arduino=cv.Version(2, 0, 0), bk72xx_arduino=cv.Version(2, 0, 0), host=cv.Version(2, 0, 0), extra_message="test 2", @@ -567,7 +567,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(1, 5, 0), esp32_arduino=cv.Version(1, 5, 0), esp8266_arduino=cv.Version(1, 5, 0), - rp2040_arduino=cv.Version(1, 5, 0), + rp2_arduino=cv.Version(1, 5, 0), bk72xx_arduino=cv.Version(1, 5, 0), host=cv.Version(1, 5, 0), max_version=True, @@ -584,7 +584,7 @@ def test_require_framework_version(framework, platform, message): esp_idf=cv.Version(0, 5, 0), esp32_arduino=cv.Version(0, 5, 0), esp8266_arduino=cv.Version(0, 5, 0), - rp2040_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), bk72xx_arduino=cv.Version(0, 5, 0), host=cv.Version(0, 5, 0), max_version=True, @@ -599,6 +599,194 @@ def test_require_framework_version(framework, platform, message): )("test") +def _setup_core_for_framework(platform: str, framework: str) -> None: + """Wire CORE.data with the minimum keys for require_framework_version / + SplitDefault to evaluate without raising KeyError.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0), + } + + +def test_only_on_rp2_passes_on_rp2_platform() -> None: + """``cv.only_on_rp2`` is the canonical family gate. It accepts any value + untouched when the configured platform is rp2.""" + _setup_core_for_framework(PLATFORM_RP2, "arduino") + assert cv.only_on_rp2("anything") == "anything" + + +def test_only_on_rp2_rejects_other_platforms() -> None: + """The same gate raises ``Invalid`` outside the rp2 platform.""" + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + with pytest.raises(Invalid, match="rp2"): + cv.only_on_rp2("anything") + + +def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None: + """``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot + warning, dedupes via CORE.data, and delegates to ``only_on_rp2``. + Repeated calls in the same run must not log again.""" + import logging + + _setup_core_for_framework(PLATFORM_RP2, "arduino") + # Reset the dedupe flag so this test is independent of order. + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + assert cv.only_on_rp2040("ok") == "ok" + first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message] + assert len(first_warnings) == 1 + assert "2027.7.0" in first_warnings[0].message + + # Second call dedupes — no additional warning is emitted. + assert cv.only_on_rp2040("ok") == "ok" + warnings_after_second = [ + r for r in caplog.records if "only_on_rp2040" in r.message + ] + assert len(warnings_after_second) == 1 + + +def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None: + """The deprecation shim must still raise on non-rp2 platforms — it + delegates to ``only_on_rp2``, so the gating behavior is preserved.""" + import logging + + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with ( + caplog.at_level(logging.WARNING, logger="esphome.config_validation"), + pytest.raises(Invalid, match="rp2"), + ): + cv.only_on_rp2040("anything") + + +def test_require_framework_version_esp32_variant_specific_key() -> None: + """ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over + the base ``esp32_arduino`` key when the configured variant matches.""" + from esphome.components.esp32 import KEY_ESP32 + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_ESP32, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3} + + # Variant-specific entry permits this version; base key would reject it. + assert ( + cv.require_framework_version( + esp32_arduino=cv.Version(5, 0, 0), # would reject + esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + +def test_require_framework_version_rp2_variant_specific_key() -> None: + """RP2 variant kwargs (``rp2_2040_arduino``) must win over the base + ``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data["rp2"] = {"variant": "RP2040"} + + # Variant key wins — base ``rp2_arduino`` (which would reject) is ignored. + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(5, 0, 0), # would reject + rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + # Without a variant kwarg the base ``rp2_arduino`` is used (fallback). + CORE.data["rp2"] = {"variant": "RP2350"} + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(1, 0, 0), + )("test") + == "test" + ) + + +def test_split_default_rp2_variant_keys() -> None: + """``SplitDefault`` resolves ``rp2__`` first, falling + back to ``rp2_`` and ``rp2_`` before the base key.""" + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + } + CORE.data["rp2"] = {"variant": "RP2040"} + + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + rp2_2040_arduino="variant-framework", + ): str, + } + ) + # Most specific (variant + framework) wins. + assert schema({}).get("full") == "variant-framework" + + # Drop the most-specific kwarg → variant-only wins. + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + ): str, + } + ) + assert schema({}).get("full") == "variant-only" + + # RP2350 variant — no rp2_2350_* kwargs → fall through to base framework. + CORE.data["rp2"] = {"variant": "RP2350"} + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="not-this", + ): str, + } + ) + assert schema({}).get("full") == "base-framework" + + def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index a61b6ae7aec..0cb0c1f62d7 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,36 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_is_rp2(self, target): + """The canonical RP2 family gate flips on for the rp2 platform.""" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + + assert target.is_rp2 is True + assert target.is_esp32 is False + assert target.is_esp8266 is False + + def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog): + """``is_rp2040`` is kept as a deprecation shim that returns whatever + ``is_rp2`` returns; both must agree across platform values. A + one-shot deprecation warning is emitted on first access and + deduped via ``CORE.data`` for the rest of the run.""" + import logging + + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + with caplog.at_level(logging.WARNING, logger="esphome.core"): + assert target.is_rp2040 is True + assert target.is_rp2040 == target.is_rp2 + + warnings = [r for r in caplog.records if "is_rp2040" in r.message] + assert len(warnings) == 1 + assert "2027.7.0" in warnings[0].message + + # Reset the dedupe so the False-platform branch also runs the shim. + target.data.pop("_core_is_rp2040_deprecated_warned", None) + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.is_rp2040 is False + assert target.is_rp2040 == target.is_rp2 + def test_firmware_bin__default(self, target): """Default platforms produce //firmware.bin.""" target.name = "test-device" diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 42e5203a737..41dd462678e 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,19 +1,13 @@ """Unit tests for esphome.loader module.""" import ast -import logging from pathlib import Path import sys import textwrap -from types import ModuleType -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch import pytest -import voluptuous as vol -from esphome import config as esphome_config, config_validation as cv -from esphome.core import CORE -import esphome.loader as loader_mod from esphome.loader import ( AliasMeta, ComponentManifest, @@ -21,6 +15,7 @@ from esphome.loader import ( _build_alias_map, _read_aliases, _replace_component_manifest, + get_alias_metadata, get_component, ) from tests.testing_helpers import ComponentManifestOverride @@ -348,17 +343,12 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub # Component aliases (renamed-platform back-compat) # --------------------------------------------------------------------------- # -# These tests pin down the substrate behind `ALIASES = [...]` on component -# `__init__.py` files: the AST scanner, the resulting global alias map, the -# Python-import `sys.meta_path` finder, the `get_component` integration, and -# the YAML pre-pass that rewrites legacy top-level keys. -# -# The framework is component-agnostic, so the integration tests inject a -# synthetic alias map (pointing a fake legacy name at the real `esp32` -# component) rather than depending on any specific renamed component. - -# A legacy name that is NOT a real component, used as a synthetic alias. -_FAKE_ALIAS = "esp32_legacy_alias" +# The framework here is the substrate behind `ALIASES = [...]` on component +# `__init__.py` files. These tests pin down the AST scanner, the resulting +# global alias map, the Python-import `sys.meta_path` finder, and the +# integration with `get_component`. The rp2 → rp2040 actual mapping in this +# repo is used as a real-world fixture; other cases use temp dirs / mocks so +# the framework's behavior is testable in isolation. def _write_component(root: Path, name: str, body: str) -> None: @@ -383,12 +373,12 @@ def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: init.write_text( textwrap.dedent("""\ ALIASES = ['old'] - ALIAS_REMOVAL_VERSION = "2027.6.0" + ALIAS_REMOVAL_VERSION = "2027.7.0" """) ) aliases, removal = _read_aliases(init, ast) assert aliases == ["old"] - assert removal == "2027.6.0" + assert removal == "2027.7.0" def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: @@ -409,28 +399,19 @@ def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> N assert removal is None -def test_read_aliases_handles_syntax_error( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: +def test_read_aliases_handles_syntax_error(tmp_path: Path) -> None: """A broken __init__.py shouldn't crash the alias scanner — it'll - surface as an ImportError elsewhere, but the scanner logs a warning and - yields nothing so other components keep working. The substring pre-filter - only skips files with no ``ALIASES`` token, so this file (which has one) - still reaches the parse.""" + surface as an ImportError elsewhere, but the scanner just yields + nothing so other components keep working. + + The source must contain the substring ``ALIASES`` so the scanner + actually attempts to parse the file; otherwise the early-return + optimization would short-circuit before reaching the parser and + this test would not exercise the syntax-error branch. + """ init = tmp_path / "__init__.py" - init.write_text("ALIASES = ['x']\ndef broken( :\n") + init.write_text("ALIASES = ['oops'\ndef broken( :\n") assert _read_aliases(init, ast) == ([], None) - assert "Could not parse" in caplog.text - - -def test_read_aliases_handles_read_error( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - """An unreadable __init__.py logs a warning and yields nothing rather - than aborting the whole component scan.""" - missing = tmp_path / "nope" / "__init__.py" - assert _read_aliases(missing, ast) == ([], None) - assert "Could not read" in caplog.text def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: @@ -480,96 +461,64 @@ def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: but possible in some test contexts), we want an empty map rather than a crash — the rest of the loader can still function.""" fake = tmp_path / "does-not-exist" + assert not fake.exists() with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): alias_map, meta_map = _build_alias_map() assert alias_map == {} assert meta_map == {} -def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: - """An alias that names an existing component package is refused: it would - hijack a live domain, and a self-alias (alias == canonical) would send - ``_lookup_module`` into infinite recursion.""" - # `newcomp` declares itself as an alias — its own package already exists. - _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") - - from esphome.core import EsphomeError - - with ( - patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), - pytest.raises(EsphomeError, match="shadows an existing component"), - ): - _build_alias_map() +# ---- Live integration against the real rp2/rp2040 mapping in this repo ---- -# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- +def test_real_alias_map_includes_rp2040() -> None: + """The rp2 component declares ``ALIASES = ['rp2040']`` in this repo; + the live alias map should surface it. This guards against future + refactors silently dropping the declaration.""" + meta = get_alias_metadata() + assert "rp2040" in meta + assert meta["rp2040"].canonical == "rp2" + assert meta["rp2040"].removal_version == "2027.7.0" -def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: - """Force the loader's alias map (used by the finder and get_component). - - Patches the lazily-built caches so both ``_get_alias_map`` and the - installed meta-path finder resolve against ``mapping`` regardless of - what the real on-disk scan would produce. - """ - monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) - - -def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: - """``get_component()`` should return the canonical manifest — every +def test_get_component_resolves_alias() -> None: + """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits the canonical component without knowing about the alias.""" - import esphome.loader as loader_mod - - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) - - canonical = get_component("esp32") - aliased = get_component(_FAKE_ALIAS) - assert canonical is not None - assert aliased is canonical + rp2 = get_component("rp2") + rp2040 = get_component("rp2040") + assert rp2 is not None + assert rp2040 is rp2 -def test_alias_finder_resolves_top_level_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``import esphome.components.`` resolves to the canonical - module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) - +def test_alias_finder_resolves_top_level_import() -> None: + """``import esphome.components.rp2040`` resolves to the canonical + module via the meta-path finder.""" + # Remove any cached entry so we exercise the finder, not sys.modules cache. + sys.modules.pop("esphome.components.rp2040", None) finder = _AliasFinder() - spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + spec = finder.find_spec("esphome.components.rp2040", None) assert spec is not None - import esphome.components.esp32 - import esphome.components.esp32_legacy_alias + import esphome.components.rp2 + import esphome.components.rp2040 - assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + assert esphome.components.rp2040 is esphome.components.rp2 -def test_alias_finder_resolves_submodule_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``from esphome.components. import boards`` routes through to - ``esphome.components.esp32.boards`` — same submodule object on both paths. - - The canonical submodule is imported first so its parent module carries - the ``boards`` attribute; ``from import boards`` then resolves - the aliased parent (via the finder) and reads that same attribute, - rather than triggering a fresh file load under the alias name. - ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" - _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) - sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) - +def test_alias_finder_resolves_submodule_import() -> None: + """``from esphome.components.rp2040 import boards`` routes through to + ``esphome.components.rp2.boards`` — same submodule object on both + paths.""" + sys.modules.pop("esphome.components.rp2040.boards", None) finder = _AliasFinder() - spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + spec = finder.find_spec("esphome.components.rp2040.boards", None) assert spec is not None - from esphome.components.esp32 import boards as canonical_boards - from esphome.components.esp32_legacy_alias import boards as aliased_boards + from esphome.components.rp2 import boards as rp2_boards + from esphome.components.rp2040 import boards as rp2040_boards - assert aliased_boards is canonical_boards + assert rp2040_boards is rp2_boards def test_alias_finder_ignores_non_components_path() -> None: @@ -581,9 +530,6 @@ def test_alias_finder_ignores_non_components_path() -> None: assert finder.find_spec("os.path", None) is None # `esphome.components` itself (no domain segment) is not a candidate. assert finder.find_spec("esphome.components", None) is None - # A real, non-aliased component domain defers to normal import machinery - # (no component declares an alias in this repo, so the live map is empty). - assert finder.find_spec("esphome.components.logger", None) is None # --------------------------------------------------------------------------- @@ -593,391 +539,121 @@ def test_alias_finder_ignores_non_components_path() -> None: # The companion to the loader-side alias map: ``esphome.config`` runs a # pre-pass over the user's parsed YAML that rewrites legacy top-level keys # to their canonical names, surfacing a one-shot deprecation warning. These -# tests inject a synthetic alias-metadata map so the rewrite behavior, the -# warning text, and the both-keys-present conflict can be tested in isolation. - - -def _patch_alias_metadata( - monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] -) -> None: - monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) +# tests pin down the rewrite behavior, the warning text, and the +# both-keys-present conflict. def test_resolve_component_aliases_renames_legacy_key( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: - """A legacy alias key should be renamed to the canonical key and a - deprecation warning citing the removal version logged.""" + """A legacy alias key ``rp2040:`` should be renamed to the canonical + ``rp2:`` and a deprecation warning citing the removal version logged.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires - config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + config = {"esphome": {"name": "test"}, "rp2040": {"board": "rpipicow"}} with caplog.at_level(logging.WARNING, logger="esphome.config"): _resolve_component_aliases(config) - assert "oldcomp" not in config - assert config["newcomp"] == {"board": "x"} + assert "rp2040" not in config + assert config["rp2"] == {"board": "rpipicow"} assert any( - "'oldcomp:' top-level key is deprecated" in record.message - and "rename it to 'newcomp:'" in record.message - and "2027.6.0" in record.message + "'rp2040:' top-level key is deprecated" in record.message + and "rename it to 'rp2:'" in record.message + and "2027.7.0" in record.message for record in caplog.records ) def test_resolve_component_aliases_dedupes_warning_within_a_run( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + caplog: pytest.LogCaptureFixture, ) -> None: """Schema validators can run twice (auto-load discovery + final pass) so the rename pass must emit the warning only once per alias per run. Deduped via ``CORE.data``; cleared between runs.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) with caplog.at_level(logging.WARNING, logger="esphome.config"): - _resolve_component_aliases({"oldcomp": {"board": "a"}}) - _resolve_component_aliases({"oldcomp": {"board": "b"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipicow"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipico2w"}}) matches = [ r for r in caplog.records - if "'oldcomp:' top-level key is deprecated" in r.message + if "'rp2040:' top-level key is deprecated" in r.message ] assert len(matches) == 1 -def test_resolve_component_aliases_rejects_both_keys_present( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_resolve_component_aliases_rejects_both_keys_present() -> None: """If the user has BOTH legacy and canonical keys, silently dropping one would hide a real misconfiguration. Raise instead.""" + import voluptuous as vol + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} - with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + config = { + "rp2": {"board": "rpipicow"}, + "rp2040": {"board": "rpipicow"}, + } + with pytest.raises(vol.Invalid, match="Both 'rp2040:'"): _resolve_component_aliases(config) -def test_resolve_component_aliases_rejects_canonical_key_after_legacy( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The both-keys conflict must be detected even when the canonical key - appears *after* the legacy key in the config (the up-front conflict - scan, not a position-dependent check).""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} - with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): - _resolve_component_aliases(config) - - -def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two different deprecated aliases of the same canonical component is - ambiguous — silently keeping one would hide a misconfiguration.""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - { - "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), - "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), - }, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} - with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): - _resolve_component_aliases(config) - - -def test_resolve_component_aliases_preserves_key_position( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The renamed canonical key keeps the legacy key's original position - rather than being moved to the end of the config.""" - from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases - from esphome.core import CORE - - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} - - _resolve_component_aliases(config) - - assert list(config) == ["esphome", "newcomp", "logger"] - - -def test_resolve_component_aliases_no_op_when_no_legacy_keys( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: +def test_resolve_component_aliases_no_op_when_no_legacy_keys() -> None: """The pre-pass must be a no-op (no warning, no mutation) for configs that already use canonical keys.""" + import logging + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases from esphome.core import CORE - _patch_alias_metadata( - monkeypatch, - {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, - ) CORE.data.pop(_ALIAS_WARNED_KEY, None) - config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + config = {"esphome": {"name": "test"}, "rp2": {"board": "rpipicow"}} original = dict(config) - with caplog.at_level(logging.WARNING, logger="esphome.config"): + with caplog_at_warning() as records: _resolve_component_aliases(config) assert config == original - assert not any("deprecated" in r.message for r in caplog.records) + assert not any("deprecated" in r.message for r in records) + _ = logging # silence unused-import in branches that don't read records -# --------------------------------------------------------------------------- -# ComponentManifest alias properties -# --------------------------------------------------------------------------- +# Helper context manager — small enough to inline rather than pull in +# caplog for the simple "did anything warn?" case above. +import contextlib # noqa: E402 -def test_component_manifest_alias_properties_default_empty() -> None: - """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` - when the component module declares neither. +@contextlib.contextmanager +def caplog_at_warning(): + """Minimal in-test caplog substitute: collect WARNING records on a + dedicated handler attached to ``esphome.config``.""" + import logging - Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the - ``getattr(..., default)`` fallback is actually exercised — a bare mock - auto-creates any attribute on access and would never hit the default.""" - mod = ModuleType("fake_component") - manifest = ComponentManifest(mod) - assert manifest.aliases == [] - assert manifest.alias_removal_version is None + logger = logging.getLogger("esphome.config") + records: list[logging.LogRecord] = [] + class _Handler(logging.Handler): + def emit(self, record): # noqa: D401 + records.append(record) -def test_component_manifest_alias_properties_read_module_values() -> None: - """The properties surface the module's declared values verbatim.""" - mod = MagicMock() - mod.ALIASES = ["legacy"] - mod.ALIAS_REMOVAL_VERSION = "2027.6.0" - manifest = ComponentManifest(mod) - assert manifest.aliases == ["legacy"] - assert manifest.alias_removal_version == "2027.6.0" - - -# --------------------------------------------------------------------------- -# Real (unpatched) lazy build + cache and remaining scanner branches -# --------------------------------------------------------------------------- - - -def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: - """Exercise the real lazy build over the actual components dir (no patch): - the first call scans and caches, the second returns the cached object.""" - monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) - first = loader_mod._get_alias_map() - second = loader_mod._get_alias_map() - assert isinstance(first, dict) - assert first is second # cached, not rebuilt on the second call - - -def test_get_alias_metadata_real_build_and_caches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) - first = loader_mod.get_alias_metadata() - second = loader_mod.get_alias_metadata() - assert isinstance(first, dict) - assert first is second - - -def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: - """Loose files and directories without an ``__init__.py`` are ignored; - only real component packages contribute to the map.""" - (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") - (tmp_path / "initless").mkdir() # a dir, but no __init__.py - _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") - - with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): - alias_map, _ = _build_alias_map() - - assert alias_map == {"legacy": "realcomp"} - - -def test_read_aliases_ignores_non_assignment_and_complex_targets( - tmp_path: Path, -) -> None: - """Non-assignment statements and assignments to non-Name targets are - skipped; only simple ``NAME = ...`` assignments are read.""" - init = tmp_path / "__init__.py" - init.write_text( - "import os\n" # non-Assign (Import) node -> skipped - "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped - "ALIASES = ['legacy']\n" - ) - aliases, _ = _read_aliases(init, ast) - assert aliases == ["legacy"] - - -# --------------------------------------------------------------------------- -# Finder / loader edge branches -# --------------------------------------------------------------------------- - - -def test_alias_finder_returns_none_when_canonical_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If an alias points at a canonical *target* that doesn't exist, the - finder declines (returns None) and lets normal import machinery report - the missing module.""" - _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) - finder = _AliasFinder() - assert finder.find_spec("esphome.components.broken_alias", None) is None - - -def test_alias_finder_reraises_when_canonical_dependency_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If the canonical module exists but fails to import one of its own - dependencies, the finder surfaces that real error instead of masking it - as an unresolved alias (which would silently fall through to a confusing - 'no module named ').""" - _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) - - def boom(name: str) -> None: - raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") - - monkeypatch.setattr("esphome.loader.importlib.import_module", boom) - finder = _AliasFinder() - with pytest.raises(ModuleNotFoundError, match="missing_dep"): - finder.find_spec("esphome.components.some_alias", None) - - -def test_install_alias_finder_is_idempotent() -> None: - """The finder is installed once at import; calling the installer again is - a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" - before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] - assert len(before) == 1 # installed at module import time - loader_mod._install_alias_finder() - after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] - assert len(after) == 1 - - -def test_get_component_alias_to_missing_canonical_returns_none( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If an alias resolves to a canonical component that can't be loaded, - ``get_component`` returns None and caches no bogus manifest.""" - _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) - loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) - - assert get_component("ghost_alias") is None - assert "ghost_alias" not in loader_mod._COMPONENT_CACHE - - -# --------------------------------------------------------------------------- -# YAML pre-pass: empty-map fast path + validate_config integration -# --------------------------------------------------------------------------- - - -def test_resolve_component_aliases_noop_when_no_aliases_declared( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When no component declares an alias, the pre-pass returns immediately - without inspecting or mutating the config.""" - from esphome.config import _resolve_component_aliases - - monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map - config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} - original = dict(config) - _resolve_component_aliases(config) - assert config == original - - -def _default_component_mock() -> Mock: - """A permissive component mock that validates any config (ALLOW_EXTRA).""" - return Mock( - auto_load=[], - is_platform_component=False, - is_platform=False, - multi_conf=False, - multi_conf_no_default=False, - dependencies=[], - conflicts_with=[], - config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), - ) - - -@pytest.mark.usefixtures("setup_core") -def test_validate_config_renames_alias_key( - mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """End-to-end: a legacy top-level key is renamed to its canonical name - before the rest of ``validate_config`` runs, and validation succeeds. - - A real ``esp32`` target platform is included so ``preload_core_config`` - is satisfied and validation runs to completion (the renamed canonical - key is loaded via the mocked, permissive component).""" - mock_get_component.side_effect = lambda name: _default_component_mock() - monkeypatch.setattr( - "esphome.loader.get_alias_metadata", - lambda: { - "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") - }, - ) - CORE.data.pop("_component_aliases_warned", None) - - raw_config = { - "esphome": {"name": "test"}, - "esp32": {"board": "esp32dev"}, - "legacyfoo": {"opt": 1}, - } - result = esphome_config.validate_config(raw_config, {}) - - assert not result.errors, f"unexpected errors: {result.errors}" - assert "newcomp" in result - assert "legacyfoo" not in result - - -@pytest.mark.usefixtures("setup_core") -def test_validate_config_reports_alias_conflict_as_error( - mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch -) -> None: - """If both the legacy and canonical keys are present, ``validate_config`` - surfaces the conflict as a config error (the ``vol.Invalid`` path).""" - mock_get_component.return_value = _default_component_mock() - monkeypatch.setattr( - "esphome.loader.get_alias_metadata", - lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, - ) - CORE.data.pop("_component_aliases_warned", None) - - raw_config = { - "esphome": {"name": "test"}, - "newcomp": {"opt": 1}, - "legacyfoo": {"opt": 2}, - } - result = esphome_config.validate_config(raw_config, {}) - - assert result.errors - assert "Both 'legacyfoo:'" in str(result.errors) + handler = _Handler(level=logging.WARNING) + logger.addHandler(handler) + prev_level = logger.level + logger.setLevel(logging.WARNING) + try: + yield records + finally: + logger.removeHandler(handler) + logger.setLevel(prev_level) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 65bf4a583e0..0442c1db16f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -94,7 +94,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -1226,7 +1226,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( mock_choose_prompt: Mock, ) -> None: """Test interactive mode shows RP2040 BOOTSEL option via picotool.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1249,7 +1249,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: """Test BOOTSEL instructions shown when no RP2040 device found.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1271,7 +1271,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( ) -> None: """Test BOOTSEL tip shown when only OTA options exist for RP2040.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1300,7 +1300,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( mock_choose_prompt: Mock, ) -> None: """Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1325,7 +1325,7 @@ def test_choose_upload_log_host_rp2040_permission_error_no_options( caplog: pytest.LogCaptureFixture, ) -> None: """Test permission warning shown when BOOTSEL device found but not accessible.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1355,7 +1355,7 @@ def test_choose_upload_log_host_rp2040_permission_error_with_ota( ) -> None: """Test permission warning shown with OTA fallback available.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1412,7 +1412,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel( mock_choose_prompt: Mock, ) -> None: """Test both serial ports and BOOTSEL option shown for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1665,7 +1665,7 @@ def test_upload_using_esptool_with_file_path( @pytest.mark.parametrize( "platform,device", [ - (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_RP2, "/dev/ttyACM0"), (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform ], ) @@ -1720,7 +1720,7 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: """Test that upload_using_platformio creates firmware.bin.signed for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1756,6 +1756,53 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( assert result == 0 +def test_upload_using_platformio_skips_signed_bin_when_already_present( + tmp_path: Path, +) -> None: + """The signed-bin copy is idempotent: if ``firmware.bin.signed`` already + exists on the RP2 build path, the upload step must not overwrite it + (and must not fail when the unsigned ``firmware.bin`` is absent).""" + setup_core(platform=PLATFORM_RP2) + + build_dir = tmp_path / "build" + build_dir.mkdir() + # Pre-existing signed bin with distinct content — must be preserved. + signed_bin = build_dir / "firmware.bin.signed" + signed_bin.write_bytes(b"already signed") + # No unsigned firmware.bin on disk — the `is_file()` guard must hold. + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"elf") + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + + with ( + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0), + ): + result = upload_using_platformio({}, "/dev/ttyACM0") + + assert result == 0 + # Pre-existing signed bin is untouched. + assert signed_bin.read_bytes() == b"already signed" + + +def test_upload_using_platformio_handles_port_none(tmp_path: Path) -> None: + """The upload step must work without a serial port (PlatformIO picks the + target itself); the ``--upload-port`` flag is only appended when a port + is provided.""" + setup_core(platform=PLATFORM_ESP32) + + with patch( + "esphome.platformio.toolchain.run_platformio_cli_run", return_value=0 + ) as mock_run: + result = upload_using_platformio({}, None) + + assert result == 0 + args = mock_run.call_args.args + assert "--upload-port" not in args + + def test_upload_program_serial_upload_failed( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -1783,7 +1830,7 @@ def test_upload_program_bootsel( mock_get_port_type: Mock, ) -> None: """Test upload_program with BOOTSEL for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 0 @@ -1804,7 +1851,7 @@ def test_upload_program_bootsel_failed( mock_get_port_type: Mock, ) -> None: """Test upload_program when BOOTSEL upload fails.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 1 @@ -1821,7 +1868,7 @@ def test_upload_program_bootsel_failed( def test_upload_using_picotool_success(tmp_path: Path) -> None: """Test upload_using_picotool succeeds.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1858,7 +1905,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None: def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: """Test upload_using_picotool when ELF file is missing.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1876,7 +1923,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: def test_upload_using_picotool_not_found(tmp_path: Path) -> None: """Test upload_using_picotool when picotool binary not found.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1896,7 +1943,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None: def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: """Test upload_using_picotool shows helpful message on permission error.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -6411,7 +6458,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: picks up the newly enumerated serial port before showing logs.""" setup_core( config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, ) args = MockArgs() diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 0ce89230d80..244e4eb5a12 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -11,6 +11,7 @@ from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS from esphome.components.esp8266.boards import ESP8266_BOARD_PINS from esphome.components.ln882x.boards import LN882X_BOARD_PINS +from esphome.components.rp2.boards import RP2_BOARD_PINS from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS from esphome.core import CORE import esphome.wizard as wz @@ -300,6 +301,31 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( assert "rtl87xx:" in generated_config +def test_wizard_write_defaults_platform_from_board_rp2( + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch +): + """ + If the platform is not explicitly set, use "RP2" when the board is in + the RP2 boards list. The generated config must use the canonical + ``rp2:`` top-level key (not the deprecated ``rp2040:`` alias). + """ + # Given + del default_config["platform"] + default_config["board"] = [*RP2_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "rp2:" in generated_config + # Guard against regressing to the legacy alias key. + assert "rp2040:" not in generated_config + + def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch): """ The safe_print_step function prints the step number and the passed description @@ -450,6 +476,34 @@ def test_wizard_accepts_default_answers_esp32( assert retval == 0 +def test_wizard_accepts_default_answers_bk72xx( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): + """ + The wizard should accept the given default answers for bk72xx. The + libretiny branch also exercises the False side of the + ``elif platform == "RP2":`` checks in the platform / board-link + elif chain (without this, those branches show as partial coverage + because only the rpipico interactive test reaches them with platform + == "RP2"). + """ + # Given + wizard_answers[1] = "BK72XX" + wizard_answers[2] = next(iter(BK72XX_BOARD_PINS)) + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + + def test_wizard_offers_better_node_name( tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): @@ -612,7 +666,7 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): # Given wizard_answers_rp2040 = [ "test-node", # Name of the node - "RP2040", # platform + "RP2", # platform (canonical name; ``RP2040`` was the legacy alias) "rpipico", # board (no WiFi support) ] config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 07f334d350d..46e60ebd8ee 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -18,7 +18,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import EsphomeError @@ -338,7 +338,7 @@ def test_storage_should_not_update_cmake_cache_when_nothing_changes( @pytest.mark.parametrize( "core_platform", - [PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_RTL87XX], + [PLATFORM_ESP8266, PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_RTL87XX], ) def test_storage_should_not_update_cmake_cache_for_non_esp32( create_storage: Callable[..., StorageJSON], From b22def399f7c1fc47f6827ad9c15636ba7711d45 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:08:56 -0400 Subject: [PATCH 297/343] [espnow] Add max_payload_size option for ESP-NOW v2 frames (#17360) --- esphome/components/espnow/__init__.py | 32 +++++++++++++++-- esphome/components/espnow/automation.h | 12 +++---- .../components/espnow/espnow_component.cpp | 14 +++++--- esphome/components/espnow/espnow_component.h | 6 ++-- esphome/components/espnow/espnow_packet.h | 35 ++++++++++++++----- .../packet_transport/espnow_transport.cpp | 14 ++++---- .../packet_transport/espnow_transport.h | 6 ++-- esphome/core/defines.h | 2 ++ tests/components/espnow/common.yaml | 1 + 9 files changed, 86 insertions(+), 36 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 13f278d3bcd..c6c90ed67ab 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -41,7 +41,7 @@ DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, cg.uint8.operator("const").operator("ptr"), - cg.uint8, + cg.uint16, ) OnUnknownPeerTrigger = espnow_ns.class_( @@ -56,6 +56,20 @@ OnBroadcastTrigger = espnow_ns.class_( CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_MAX_PAYLOAD_SIZE = "max_payload_size" + +# Payload limits of ESP-NOW v1 and v2 frames. The radio negotiates the +# protocol version per peer on its own; the option only sizes this device's +# packet buffers, whose static RAM cost is proportional to it (~8 KB at 250 +# bytes, ~44 KB at 1470). +ESPNOW_PAYLOAD_V1 = 250 +ESPNOW_PAYLOAD_V2 = 1470 + +# Config-time cap for action payloads. The per-device limit is the +# ``max_payload_size`` option, which the action schema cannot see; send() +# enforces it at runtime. +MAX_ESPNOW_PACKET_SIZE = ESPNOW_PAYLOAD_V2 + CONF_PEERS = "peers" CONF_ON_SENT = "on_sent" CONF_ON_UNKNOWN_PEER = "on_unknown_peer" @@ -63,7 +77,15 @@ CONF_ON_BROADCAST = "on_broadcast" CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes + +def _validate_max_payload_size(value: int) -> int: + if value > ESPNOW_PAYLOAD_V1: + return cv.require_framework_version( + esp_idf=cv.Version(5, 4, 0), + esp32_arduino=cv.Version(3, 2, 0), + extra_message="ESP-NOW v2 frames need an ESP-NOW v2 capable framework", + )(value) + return value def validate_channel(value): @@ -78,6 +100,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(ESPNowComponent), cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): validate_channel, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_MAX_PAYLOAD_SIZE, default=ESPNOW_PAYLOAD_V1): cv.All( + cv.int_range(min=1, max=ESPNOW_PAYLOAD_V2), _validate_max_payload_size + ), cv.Optional(CONF_AUTO_ADD_PEER, default=False): cv.boolean, cv.Optional(CONF_PEERS): cv.ensure_list(cv.mac_address), cv.Optional(CONF_ON_UNKNOWN_PEER): automation.validate_automation( @@ -113,7 +138,7 @@ async def _trigger_to_code(config): [ (ESPNowRecvInfoConstRef, "info"), (cg.uint8.operator("const").operator("ptr"), "data"), - (cg.uint8, "size"), + (cg.uint16, "size"), ], config, ) @@ -125,6 +150,7 @@ async def to_code(config): await cg.register_component(var, config) cg.add_define("USE_ESPNOW") + cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 5e995aff533..e4d01bb1a8b 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -119,7 +119,7 @@ template class SetChannelAction final : public Action, pu } }; -class OnReceiveTrigger final : public Trigger, +class OnReceiveTrigger final : public Trigger, public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { @@ -128,7 +128,7 @@ class OnReceiveTrigger final : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -141,15 +141,15 @@ class OnReceiveTrigger final : public Trigger, +class OnUnknownPeerTrigger final : public Trigger, public ESPNowUnknownPeerHandler { public: - bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger final : public Trigger, +class OnBroadcastTrigger final : public Trigger, public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { @@ -157,7 +157,7 @@ class OnBroadcastTrigger final : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91f2c067ca7..f28d7f33544 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,6 +4,7 @@ #include "espnow_err.h" +#include #include #include "esphome/core/application.h" @@ -96,9 +97,9 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger - // frame would overflow packet_.receive.data. - if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // larger frame would overflow packet_.receive.data. + if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { global_esp_now->receive_packet_queue_.increment_dropped_count(); return; } @@ -285,11 +286,14 @@ void ESPNowComponent::loop() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + // Cap the hex dump at a v1 frame: a full v2 frame would need a + // ~4.4 KB stack buffer. char hex_buf[format_hex_pretty_size(ESP_NOW_MAX_DATA_LEN)]; format_mac_addr_upper(info.src_addr, src_buf); format_mac_addr_upper(info.des_addr, dst_buf); ESP_LOGV(TAG, "<<< [%s -> %s] %s", src_buf, dst_buf, - format_hex_pretty_to(hex_buf, packet->packet_.receive.data, packet->packet_.receive.size)); + format_hex_pretty_to(hex_buf, packet->packet_.receive.data, + std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { @@ -362,7 +366,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; - } else if (size > ESP_NOW_MAX_DATA_LEN) { + } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; } else if (!esp_now_is_peer_exist(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index eacc3eb886d..d95255c5df7 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -62,7 +62,7 @@ class ESPNowUnknownPeerHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow packets @@ -74,7 +74,7 @@ class ESPNowReceivedPacketHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow broadcast packets /// Components should inherit from this class to handle incoming ESPNow data @@ -85,7 +85,7 @@ class ESPNowBroadcastHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; class ESPNowComponent final : public Component { diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index b6192a0d41e..fb125864fb6 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -19,6 +19,23 @@ namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static const uint8_t ESPNOW_MULTICAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}; +// Maximum payload this component sends and receives, from the +// ``max_payload_size`` option. The radio stack speaks ESP-NOW v2 regardless +// (negotiated per peer); payloads beyond the v1 limit (250 bytes) are opt-in +// because the packet pools are statically sized from this, so their RAM cost +// is proportional (~8 KB at 250 bytes, ~44 KB at the v2 limit of 1470). +#ifndef USE_ESPNOW_MAX_PAYLOAD_SIZE +#define USE_ESPNOW_MAX_PAYLOAD_SIZE ESP_NOW_MAX_DATA_LEN +#endif +static constexpr uint16_t ESPNOW_MAX_DATA_LEN = USE_ESPNOW_MAX_PAYLOAD_SIZE; +#ifdef ESP_NOW_MAX_DATA_LEN_V2 +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN_V2, + "espnow max_payload_size cannot exceed the ESP-NOW v2 frame limit"); +#else +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, + "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); +#endif + struct WifiPacketRxControl { int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or @@ -78,10 +95,10 @@ class ESPNowPacket { union { // NOLINTNEXTLINE(readability-identifier-naming) struct received_data { - ESPNowRecvInfo info; // Information about the received packet - uint8_t data[ESP_NOW_MAX_DATA_LEN]; // Data received in the packet - uint8_t size; // Size of the received data - WifiPacketRxControl rx_ctrl; // Status of the received packet + ESPNowRecvInfo info; // Information about the received packet + uint8_t data[ESPNOW_MAX_DATA_LEN]; // Data received in the packet + uint16_t size; // Size of the received data + WifiPacketRxControl rx_ctrl; // Status of the received packet } receive; // NOLINTNEXTLINE(readability-identifier-naming) @@ -144,15 +161,15 @@ class ESPNowSendPacket { this->callback_ = nullptr; // Reset callback } - uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to - uint8_t data_[ESP_NOW_MAX_DATA_LEN]{0}; // Data to send - uint8_t size_{0}; // Size of the data to send, must be <= ESP_NOW_MAX_DATA_LEN - send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete + uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to + uint8_t data_[ESPNOW_MAX_DATA_LEN]{0}; // Data to send + uint16_t size_{0}; // Size of the data to send, must be <= ESPNOW_MAX_DATA_LEN + send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete private: void init_data_(const uint8_t *peer_address, const uint8_t *payload, size_t size) { memcpy(this->address_, peer_address, ESP_NOW_ETH_ALEN); - if (size > ESP_NOW_MAX_DATA_LEN) { + if (size > ESPNOW_MAX_DATA_LEN) { this->size_ = 0; return; } diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 1e37073321e..b7686f23d6f 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -42,8 +42,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { return; } - if (buf.size() > ESP_NOW_MAX_DATA_LEN) { - ESP_LOGE(TAG, "Packet too large: %zu bytes (max %d)", buf.size(), ESP_NOW_MAX_DATA_LEN); + if (buf.size() > ESPNOW_MAX_DATA_LEN) { + ESP_LOGE(TAG, "Packet too large: %zu bytes (max %u)", buf.size(), (unsigned) ESPNOW_MAX_DATA_LEN); return; } @@ -55,8 +55,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { }); } -bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], +bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { @@ -70,9 +70,9 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; // Allow other handlers to run } -bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], - info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); +bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, + info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { ESP_LOGW(TAG, "Received empty or null broadcast packet"); diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 7e1d08618b5..51069b64153 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -24,12 +24,12 @@ class ESPNowTransport final : public packet_transport::PacketTransport, } // ESPNow handler interface - bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; - bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; protected: void send_packet(const std::vector &buf) const override; - size_t get_max_packet_size() override { return ESP_NOW_MAX_DATA_LEN; } + size_t get_max_packet_size() override { return ESPNOW_MAX_DATA_LEN; } bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3e8b0829c51..cdc26c92228 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -242,6 +242,8 @@ #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM +#define USE_ESPNOW +#define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f40..ae43baa41a5 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,6 +2,7 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 + max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: From 0b311962b5ff529c1eb854233ac80d6e7b291475 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:09:51 -0500 Subject: [PATCH 298/343] Bump bundled esphome-device-builder to 1.2.0 (#17430) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a54bf3e79e8..c01a2069f7a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0 RUN \ platformio settings set enable_telemetry No \ From ebff49072e0acf19102991c6521ae8038abbe952 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 6 Jul 2026 16:12:25 -0700 Subject: [PATCH 299/343] [modbus] Store ModbusFrame inline to cut per-frame heap churn (#17282) Co-authored-by: Claude --- esphome/components/modbus/modbus.cpp | 25 ++--- esphome/components/modbus/modbus.h | 30 ++++-- esphome/core/helpers.h | 11 +- tests/components/modbus/heap_probe_test.cpp | 106 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 tests/components/modbus/heap_probe_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index eefab7967f6..527d57fcd78 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -51,7 +51,7 @@ void ModbusClientHub::loop() { // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response if (this->waiting_for_response_.has_value()) { ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_address = wfr.frame.data.data()[0]; if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, @@ -270,8 +270,8 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct // Check if the response matches the expected address and function code ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); - uint8_t expected_address = wfr.frame.data.get()[0]; - uint8_t expected_function_code = wfr.frame.data.get()[1]; + uint8_t expected_address = wfr.frame.data.data()[0]; + uint8_t expected_function_code = wfr.frame.data.data()[1]; if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { ESP_LOGW(TAG, "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 @@ -458,7 +458,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { ESP_LOGE(TAG, "Attempted to send while transmission blocked"); return false; } - if (frame.size > MAX_FRAME_SIZE) { + if (frame.size() > MAX_FRAME_SIZE) { ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); return false; } @@ -470,13 +470,13 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->digital_write(true); - this->write_array(frame.data.get(), frame.size); + this->write_array(frame.data.data(), frame.size()); this->flush(); this->flow_control_pin_->digital_write(false); this->last_send_tx_offset_ = 0; } else { - this->write_array(frame.data.get(), frame.size); - this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; + this->write_array(frame.data.data(), frame.size()); + this->last_send_tx_offset_ = frame.size() * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } uint32_t now = millis(); @@ -484,7 +484,7 @@ bool Modbus::send_frame_(const ModbusFrame &frame) { char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", - format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + format_hex_pretty_to(hex_buf, frame.data.data(), frame.size()), now - this->last_send_, now - this->last_modbus_byte_); this->last_send_ = now; return true; @@ -590,12 +590,13 @@ void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t p void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { // Remove any pending commands for this address from the tx buffer auto &tx_buffer = this->tx_buffer_; - tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), - [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), - tx_buffer.end()); + tx_buffer.erase( + std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data.data()[0] == address; }), + tx_buffer.end()); if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { - if (this->waiting_for_response_.value().frame.data[0] == address) { + if (this->waiting_for_response_.value().frame.data.data()[0] == address) { ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); // Invalidate the waiting device so it won't process a response. this->waiting_for_response_.value().device = nullptr; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index d995c441ade..e48c8c298a6 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -18,19 +18,27 @@ namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -struct ModbusFrame { - // Frame with exact-size allocation to avoid std::vector overhead - std::unique_ptr data; - uint16_t size; // Modbus RTU max is 256 bytes +// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes +// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation. +static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8; - ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) - : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { - data[0] = address; - memcpy(data.get() + 1, pdu, pdu_len); - auto crc = crc16(data.get(), pdu_len + 1); - data[pdu_len + 1] = crc >> 0; - data[pdu_len + 2] = crc >> 8; +struct ModbusFrame { + // Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger + // multi-register or custom frames spill to a single heap allocation. This keeps the common, + // high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn. + // The buffer tracks its own length, so no separate size field is needed. + SmallInlineBuffer data; // Modbus RTU max is 256 bytes + + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) { + uint8_t *buf = this->data.init(pdu_len + 3); + buf[0] = address; + memcpy(buf + 1, pdu, pdu_len); + auto crc = crc16(buf, pdu_len + 1); + buf[pdu_len + 1] = crc >> 0; + buf[pdu_len + 2] = crc >> 8; } + + uint16_t size() const { return static_cast(this->data.size()); } }; class Modbus : public uart::UARTDevice, public Component { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f39b5aa4d0f..e862d015da2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -184,8 +184,10 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; - /// Set buffer contents, allocating heap if needed - void set(const uint8_t *src, size_t size) { + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. + /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are + /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. + uint8_t *init(size_t size) { // Free existing heap allocation if switching from heap to inline or different heap size if (!this->is_inline_() && (size <= InlineSize || size != this->len_)) { delete[] this->heap_; @@ -196,9 +198,12 @@ template class SmallInlineBuffer { this->heap_ = new uint8_t[size]; // NOLINT(cppcoreguidelines-owning-memory) } this->len_ = size; - memcpy(this->data(), src, size); + return this->data(); } + /// Set buffer contents, allocating heap if needed + void set(const uint8_t *src, size_t size) { memcpy(this->init(size), src, size); } + uint8_t *data() { return this->is_inline_() ? this->inline_ : this->heap_; } const uint8_t *data() const { return this->is_inline_() ? this->inline_ : this->heap_; } size_t size() const { return this->len_; } diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp new file mode 100644 index 00000000000..af43c6e5e38 --- /dev/null +++ b/tests/components/modbus/heap_probe_test.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/modbus/modbus.h" + +// The allocation counters rely on AddressSanitizer's malloc hooks. The cpp_unit_test harness always +// builds with ASan, so this is exercised in CI; the fallback only applies to out-of-harness builds. +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define HEAP_PROBE_HAS_ASAN +#endif + +#ifdef HEAP_PROBE_HAS_ASAN + +// Allocation counters fed by ASan's malloc hooks; sampled tightly around the calls under test. +static std::atomic g_alloc_count{0}; +static std::atomic g_alloc_bytes{0}; + +static void malloc_hook(const volatile void *, size_t size) { + g_alloc_count++; + g_alloc_bytes += size; +} +static void free_hook(const volatile void *) {} + +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); + +[[maybe_unused]] static const int g_hooks_installed = __sanitizer_install_malloc_and_free_hooks(malloc_hook, free_hook); + +namespace esphome::modbus::testing { + +namespace { + +struct Sample { + size_t count; + size_t bytes; +}; + +template Sample sample(F &&f) { + size_t c0 = g_alloc_count.load(), b0 = g_alloc_bytes.load(); + f(); + return {g_alloc_count.load() - c0, g_alloc_bytes.load() - b0}; +} + +} // namespace + +// Typical frames (reads and single-register/coil writes are exactly address + 5-byte PDU + CRC = 8 +// bytes) fit the SmallInlineBuffer and are built with zero heap allocations; only larger frames spill +// to a single allocation. +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // 5 bytes -> 8-byte frame, inline + Sample typical = sample([&] { + ModbusFrame frame(0x02, read_pdu, sizeof(read_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_typical count=%zu bytes=%zu\n", typical.count, typical.bytes); + EXPECT_EQ(typical.count, 0u); + + uint8_t large_pdu[250] = {0x10}; // multi-register write -> 253-byte frame, spills once + Sample large = sample([&] { + ModbusFrame frame(0x02, large_pdu, sizeof(large_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_large count=%zu bytes=%zu\n", large.count, large.bytes); + EXPECT_EQ(large.count, 1u); +} + +// Queueing typical commands is fully allocation-free: the frame fits the inline buffer and the tx +// deque's first block is already allocated when the hub is constructed. (A queue deeper than one +// deque block - roughly a dozen commands - would allocate further blocks.) +TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { + ModbusClientHub hub; + ModbusClientDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + + constexpr int n = 12; + size_t total = 0; + for (int i = 0; i != n; i++) { + total += sample([&] { device.send_pdu(req); }).count; + } + printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); + EXPECT_EQ(total, 0u); +} + +} // namespace esphome::modbus::testing + +#else // !HEAP_PROBE_HAS_ASAN + +namespace esphome::modbus::testing { +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +} // namespace esphome::modbus::testing + +#endif // HEAP_PROBE_HAS_ASAN From c4689989c78aee50f8e065d9d7e0e31f0dcb2acd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:42:18 +1200 Subject: [PATCH 300/343] Mark configurable classes as final (20/21: wts01-zephyr_ble_server) (#16971) --- esphome/components/wts01/wts01.h | 2 +- esphome/components/x9c/x9c.h | 2 +- esphome/components/xdb401/xdb401.h | 2 +- esphome/components/xgzp68xx/xgzp68xx.h | 2 +- esphome/components/xiaomi_ble/xiaomi_ble.h | 2 +- esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h | 2 +- esphome/components/xiaomi_cgg1/xiaomi_cgg1.h | 2 +- esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h | 6 +++--- esphome/components/xiaomi_gcls002/xiaomi_gcls002.h | 2 +- esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h | 2 +- esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h | 2 +- esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h | 2 +- esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h | 2 +- esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h | 2 +- esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h | 2 +- esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h | 2 +- esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h | 2 +- esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h | 2 +- esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h | 2 +- esphome/components/xiaomi_miscale/xiaomi_miscale.h | 2 +- esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h | 6 +++--- esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h | 6 +++--- esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h | 2 +- esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h | 6 +++--- esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 2 +- esphome/components/xl9535/xl9535.h | 4 ++-- esphome/components/xpt2046/touchscreen/xpt2046.h | 6 +++--- esphome/components/yashima/yashima.h | 2 +- esphome/components/zephyr/cdc_acm.h | 2 +- esphome/components/zephyr/gpio.h | 2 +- esphome/components/zephyr_ble_server/ble_server.h | 4 ++-- 31 files changed, 43 insertions(+), 43 deletions(-) diff --git a/esphome/components/wts01/wts01.h b/esphome/components/wts01/wts01.h index 17d4dc57a2f..2a284ac86e7 100644 --- a/esphome/components/wts01/wts01.h +++ b/esphome/components/wts01/wts01.h @@ -8,7 +8,7 @@ namespace esphome::wts01 { constexpr uint8_t PACKET_SIZE = 9; -class WTS01Sensor : public sensor::Sensor, public uart::UARTDevice, public Component { +class WTS01Sensor final : public sensor::Sensor, public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 112f0405d7d..1cea15c26f6 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -6,7 +6,7 @@ namespace esphome::x9c { -class X9cOutput : public output::FloatOutput, public Component { +class X9cOutput final : public output::FloatOutput, public Component { public: void set_cs_pin(InternalGPIOPin *pin) { cs_pin_ = pin; } void set_inc_pin(InternalGPIOPin *pin) { inc_pin_ = pin; } diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h index 674d26fe8e2..670425e69e4 100644 --- a/esphome/components/xdb401/xdb401.h +++ b/esphome/components/xdb401/xdb401.h @@ -6,7 +6,7 @@ namespace esphome::xdb401 { -class XDB401Component : public PollingComponent, public i2c::I2CDevice { +class XDB401Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.h b/esphome/components/xgzp68xx/xgzp68xx.h index 1bab9b091aa..d9aec6e5cc2 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.h +++ b/esphome/components/xgzp68xx/xgzp68xx.h @@ -20,7 +20,7 @@ enum XGZP68XXOversampling : uint8_t { XGZP68XX_OVERSAMPLING_UNKNOWN = (uint8_t) -1, }; -class XGZP68XXComponent : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class XGZP68XXComponent final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: SUB_SENSOR(temperature) SUB_SENSOR(pressure) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index a4ecca0c66e..1ebcf0e2f55 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -72,7 +72,7 @@ optional parse_xiaomi_header(const esp32_ble_tracker::Service bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 02d098c31b0..36068ae2272 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index d49e3a08d1e..7633458cb8f 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 28a7a3ae2d6..0fa6c76e547 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_cgpr1 { -class XiaomiCGPR1 : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGPR1 final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index e14077adb0f..668133f364b 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index 8bc63990655..cb53b47f6f3 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index 812e3a7d8f4..fa2f4615347 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -8,7 +8,7 @@ namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 2bdd6102be0..3eda1b98591 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index aaf34f899fe..122c6776c98 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index e45596f966d..09256047aeb 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index 23efcbf8fc4..efd758b9726 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index 03462b850fd..ecdbd412cbd 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index e169afc6519..86afef45718 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index daacd6be867..042a5034f11 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 225c9ff1898..3570f70a169 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index c75a22c9fb8..3213f5d6de0 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -16,7 +16,7 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index ee4ed525209..da02dee0038 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_mjyd02yla { -class XiaomiMJYD02YLA : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMJYD02YLA final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index a6d8abc5bf6..4751e35e65a 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -9,9 +9,9 @@ namespace esphome::xiaomi_mue4094rt { -class XiaomiMUE4094RT : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMUE4094RT final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index cc6a334a20d..0d3427cc4db 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -15,7 +15,7 @@ namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0b0cb8db0bc..05739594734 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_wx08zm { -class XiaomiWX08ZM : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiWX08ZM final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index 9bab943ab94..c7d20aa356e 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 253ce762734..11fb3acc8d0 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -17,7 +17,7 @@ enum { XL9535_CONFIG_PORT_1_REGISTER = 0x07, }; -class XL9535Component : public Component, public i2c::I2CDevice { +class XL9535Component final : public Component, public i2c::I2CDevice { public: bool digital_read(uint8_t pin); void digital_write(uint8_t pin, bool value); @@ -28,7 +28,7 @@ class XL9535Component : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin : public GPIOPin { +class XL9535GPIOPin final : public GPIOPin { public: void set_parent(XL9535Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index f619e06fb77..8fe9b7cc436 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -11,9 +11,9 @@ namespace esphome::xpt2046 { using namespace touchscreen; -class XPT2046Component : public Touchscreen, - public spi::SPIDevice { +class XPT2046Component final : public Touchscreen, + public spi::SPIDevice { public: /// Set the threshold for the touch detection. void set_threshold(int16_t threshold) { this->threshold_ = threshold; } diff --git a/esphome/components/yashima/yashima.h b/esphome/components/yashima/yashima.h index 336b28f5c52..864b3fce664 100644 --- a/esphome/components/yashima/yashima.h +++ b/esphome/components/yashima/yashima.h @@ -9,7 +9,7 @@ namespace esphome::yashima { -class YashimaClimate : public climate::Climate, public Component { +class YashimaClimate final : public climate::Climate, public Component { public: void setup() override; void set_transmitter(remote_transmitter::RemoteTransmitterComponent *transmitter) { diff --git a/esphome/components/zephyr/cdc_acm.h b/esphome/components/zephyr/cdc_acm.h index 4dc14397d83..9d11d4b5751 100644 --- a/esphome/components/zephyr/cdc_acm.h +++ b/esphome/components/zephyr/cdc_acm.h @@ -7,7 +7,7 @@ namespace esphome::zephyr { -class CdcAcm : public Component { +class CdcAcm final : public Component { public: CdcAcm(); void setup() override; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 19d68cfb2be..71d1620a677 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -16,7 +16,7 @@ struct ZephyrGPIOInterrupt { void *arg{nullptr}; }; -class ZephyrGPIOPin : public InternalGPIOPin { +class ZephyrGPIOPin final : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index bf69c52b126..223dbf7ac98 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -6,7 +6,7 @@ namespace esphome::zephyr_ble_server { -class BLEServer : public Component { +class BLEServer final : public Component { public: void setup() override; void dump_config() override; @@ -21,7 +21,7 @@ class BLEServer : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction : public Action { +template class BLENumericComparisonReplyAction final : public Action { public: explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} From 2dd7ac090f66212357cf4d1dbff4e08ccaace2bf Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:37:44 +1000 Subject: [PATCH 301/343] [mipi_spi] Add M5STACK ATOM3SR display (#17344) --- esphome/components/mipi_spi/models/ili.py | 72 ------------------- esphome/components/mipi_spi/models/m5stack.py | 71 ++++++++++++++++++ 2 files changed, 71 insertions(+), 72 deletions(-) create mode 100644 esphome/components/mipi_spi/models/m5stack.py diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5598a51073f..812e491c626 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -10,7 +10,6 @@ from esphome.components.mipi import ( GMCTR, GMCTRN1, GMCTRP1, - IDMOFF, IFCTR, IFMODE, INVCTR, @@ -23,7 +22,6 @@ from esphome.components.mipi import ( PWCTR5, PWSET, PWSETN, - SETEXTC, VMCTR, VMCTR1, VMCTR2, @@ -32,60 +30,6 @@ from esphome.components.mipi import ( ) from esphome.components.spi import TYPE_OCTAL -DriverChip( - "M5CORE", - width=320, - height=240, - cs_pin=14, - dc_pin=27, - reset_pin=33, - initsequence=( - (SETEXTC, 0xFF, 0x93, 0x42), - (PWCTR1, 0x12, 0x12), - (PWCTR2, 0x03), - (VMCTR1, 0xF2), - (IFMODE, 0xE0), - (0xF6, 0x01, 0x00, 0x00), - ( - GMCTRP1, - 0x00, - 0x0C, - 0x11, - 0x04, - 0x11, - 0x08, - 0x37, - 0x89, - 0x4C, - 0x06, - 0x0C, - 0x0A, - 0x2E, - 0x34, - 0x0F, - ), - ( - GMCTRN1, - 0x00, - 0x0B, - 0x11, - 0x05, - 0x13, - 0x09, - 0x33, - 0x67, - 0x48, - 0x07, - 0x0E, - 0x0B, - 0x2E, - 0x33, - 0x0F, - ), - (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), - (IDMOFF,), - ), -) ILI9341 = DriverChip( "ILI9341", mirror_x=True, @@ -174,22 +118,6 @@ ILI9342 = DriverChip( ), ) -# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation -ILI9341.extend( - "M5CORE2", - # Reset native dimensions due to axis swap. - native_width=320, - native_height=240, - width=320, - height=240, - mirror_x=False, - cs_pin=5, - dc_pin=15, - invert_colors=True, - pixel_mode="18bit", - data_rate="40MHz", -) - DriverChip( "ILI9481", mirror_x=True, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py new file mode 100644 index 00000000000..81bb186278b --- /dev/null +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -0,0 +1,71 @@ +from esphome.components.mipi import ( + DFUNCTR, + GMCTRN1, + GMCTRP1, + IDMOFF, + IFMODE, + PWCTR1, + PWCTR2, + SETEXTC, + VMCTR1, + DriverChip, +) + +from .ili import ILI9341, ST7789V + +# fmt: off +DriverChip( + "M5CORE", + width=320, + height=240, + cs_pin=14, + dc_pin=27, + reset_pin=33, + initsequence=( + (SETEXTC, 0xFF, 0x93, 0x42), + (PWCTR1, 0x12, 0x12), + (PWCTR2, 0x03), + (VMCTR1, 0xF2), + (IFMODE, 0xE0), + (0xF6, 0x01, 0x00, 0x00), + (GMCTRP1, 0x00, 0x0C, 0x11, 0x04, 0x11, 0x08, 0x37, 0x89, 0x4C, 0x06, 0x0C, 0x0A, 0x2E, 0x34, 0x0F,), + (GMCTRN1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x2E, 0x33, 0x0F,), + (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), + (IDMOFF,), + ), +) + +# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation +ILI9341.extend( + "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, + width=320, + height=240, + mirror_x=False, + cs_pin=5, + dc_pin=15, + invert_colors=True, + pixel_mode="18bit", + data_rate="40MHz", +) + +GC9107 = ST7789V.extend( + "GC9107", + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=1, +) + +GC9107.extend( + "M5STACK-ATOMS3R-GC9107", + data_rate="40MHz", + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, +) From d9998eff20fbc02f21d41484fa68ddcb891305ab Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 19:39:18 -0500 Subject: [PATCH 302/343] [esp32] Add software OTA downgrade protection (#17315) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32/__init__.py | 67 +++++++++++++++++++ esphome/components/ota/ota_backend.cpp | 28 ++++++++ esphome/components/ota/ota_backend.h | 15 +++++ .../components/ota/ota_backend_esp_idf.cpp | 20 ++++++ esphome/core/defines.h | 1 + esphome/espota2.py | 6 ++ tests/component_tests/esp32/test_esp32.py | 33 +++++++++ ...ota_downgrade_protection.esp32-s3-idf.yaml | 22 ++++++ tests/components/md5/__init__.py | 9 +++ tests/components/ota/test_version_compare.cpp | 52 ++++++++++++++ 11 files changed, 254 insertions(+) create mode 100644 tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml create mode 100644 tests/components/md5/__init__.py create mode 100644 tests/components/ota/test_version_compare.cpp diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 85878a6306d..6f4fa9aaa7a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -16,6 +16,7 @@ CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a5528da6727..5a7ddb6c762 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,6 +11,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg +from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_PATH, CONF_PLATFORM_VERSION, CONF_PLATFORMIO_OPTIONS, + CONF_PROJECT, CONF_REF, CONF_SAFE_MODE, CONF_SIZE, @@ -1098,6 +1100,50 @@ def _detect_variant(value): return value +def _ota_downgrade_protection_errors( + project_version: str | None, signed_ota_enabled: bool +) -> list[cv.Invalid]: + """Validate prerequisites for OTA downgrade protection. + + Called only when the feature is enabled. Returns a ``cv.Invalid`` for each + unmet requirement: a dotted-numeric project version (the firmware version + compared on-device) and signed OTA (so the embedded version cannot be + forged). + """ + path = [CONF_FRAMEWORK, CONF_ADVANCED, CONF_ENABLE_OTA_DOWNGRADE_PROTECTION] + errs: list[cv.Invalid] = [] + if not project_version: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires a " + f"'{CONF_PROJECT}' with a '{CONF_VERSION}' to be set in the " + f"'{CONF_ESPHOME}' section; this version is the firmware version " + "compared during OTA.", + path=path, + ) + ) + elif not re.fullmatch(r"\d+(\.\d+)*", project_version): + # The on-device comparison parses dotted-numeric versions only. + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires the " + f"'{CONF_PROJECT}' '{CONF_VERSION}' to be dotted-numeric (such " + f"as '1.2.3'), got '{project_version}'.", + path=path, + ) + ) + if not signed_ota_enabled: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires " + f"'{CONF_SIGNED_OTA_VERIFICATION}' to be enabled; without signed " + "OTA the embedded version cannot be trusted.", + path=path, + ) + ) + return errs + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1303,6 +1349,14 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project = full_config[CONF_ESPHOME].get(CONF_PROJECT) + errs.extend( + _ota_downgrade_protection_errors( + project[CONF_VERSION] if project else None, + bool(advanced.get(CONF_SIGNED_OTA_VERIFICATION)), + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -1540,6 +1594,9 @@ FRAMEWORK_SCHEMA = cv.Schema( min=8192, max=32768 ), cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, + cv.Optional( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False + ): cv.boolean, cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( cv.Schema( { @@ -2358,6 +2415,16 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) cg.add_define("USE_OTA_ROLLBACK") + # Enable software OTA downgrade protection. Embed the project version into + # the image's esp_app_desc_t so the OTA backend can compare it against the + # running version (final_validate guarantees a dotted-numeric project + # version and that signed OTA is enabled). + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project_version = CORE.config[CONF_ESPHOME][CONF_PROJECT][CONF_VERSION] + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER_FROM_CONFIG", True) + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER", project_version) + cg.add_define("USE_OTA_DOWNGRADE_PROTECTION") + # Enable signed app verification without hardware secure boot if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 17949de642f..0447b968a3b 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -2,6 +2,34 @@ namespace esphome::ota { +bool version_is_older(const char *candidate, const char *reference) { + if (candidate == nullptr || reference == nullptr) + return false; + while (true) { + uint32_t a = 0; + while (*candidate >= '0' && *candidate <= '9') { + a = a * 10 + static_cast(*candidate - '0'); + candidate++; + } + uint32_t b = 0; + while (*reference >= '0' && *reference <= '9') { + b = b * 10 + static_cast(*reference - '0'); + reference++; + } + if (a != b) + return a < b; + // Components equal so far; advance past a single separator on each side. + const bool a_more = (*candidate == '.'); + const bool b_more = (*reference == '.'); + if (a_more) + candidate++; + if (b_more) + reference++; + if (!a_more && !b_more) + return false; // Both strings exhausted with all components equal. + } +} + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index de236c19513..01be46a5187 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -46,9 +46,24 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91, OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, + OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; +/** Compare two dotted-numeric version strings (such as "1.2.3"). + * + * Returns true when @p candidate represents a strictly older (lower) firmware + * version than @p reference. Each dot-separated component is parsed as an + * integer and compared left-to-right; absent trailing components count as 0, + * so "1.2" and "1.2.0" are equal. Equal versions return false so that + * re-flashing the same version is permitted. + * + * Used for software OTA downgrade protection. Inputs come from the project + * version embedded in the signed firmware image, which is validated to be + * dotted-numeric at config time. Non-digit characters terminate a component. + */ +bool version_is_older(const char *candidate, const char *reference); + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ac765d8018f..8fd21f42bd9 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -9,6 +9,9 @@ #include #include #include +#ifdef USE_OTA_DOWNGRADE_PROTECTION +#include +#endif namespace esphome::ota { @@ -159,6 +162,23 @@ OTAResponseTypes IDFOTABackend::end() { } #endif if (err == ESP_OK) { +#ifdef USE_OTA_DOWNGRADE_PROTECTION + // The image is written and (when signing is enabled) signature-verified by + // esp_ota_end(), so its embedded project version can be trusted. Reject the + // update if it is older than the running version by leaving the boot + // partition unchanged -- the staged image simply never boots. + esp_app_desc_t incoming; + esp_err_t desc_err = esp_ota_get_partition_description(this->partition_, &incoming); + if (desc_err != ESP_OK) { + // Couldn't read the staged image's version, so the comparison is skipped. + // Warn so the bypassed check is observable rather than silent. + ESP_LOGW(TAG, "Downgrade protection: could not read image version (err=0x%X); allowing update", desc_err); + } else if (version_is_older(incoming.version, ESPHOME_PROJECT_VERSION)) { + ESP_LOGE(TAG, "Rejecting downgrade: image version '%s' is older than running version '%s'", incoming.version, + ESPHOME_PROJECT_VERSION); + return OTA_RESPONSE_ERROR_VERSION_DOWNGRADE; + } +#endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { return OTA_RESPONSE_OK; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cdc26c92228..1d09bb5c5cd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -239,6 +239,7 @@ #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION +#define USE_OTA_DOWNGRADE_PROTECTION #define USE_ESP32_MIN_CHIP_REVISION_SET #define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM diff --git a/esphome/espota2.py b/esphome/espota2.py index 266702c1420..fa15c1dda21 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -52,6 +52,7 @@ RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 +RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -157,6 +158,11 @@ _ERROR_MESSAGES: dict[int, str] = { "the bootloader update without rebooting the device. If the device " "fails to boot, recover it via a serial flash." ), + RESPONSE_ERROR_VERSION_DOWNGRADE: ( + "The device rejected the update because it has OTA downgrade protection " + "enabled: the new firmware's version must be newer than the version the " + "device is currently running." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index cea34bef7cf..1b189c63310 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -13,6 +13,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, + _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, ) from esphome.components.esp32.const import ( @@ -560,3 +561,35 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False # WiFi present alongside BT -> WiFi stack must stay enabled. assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + + +def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: + assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_accepts_calendar_version() -> None: + assert _ota_downgrade_protection_errors("2024.12.0", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_requires_project_version() -> None: + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=True) + assert len(errs) == 1 + assert "version" in str(errs[0]) + + +def test_downgrade_protection_rejects_non_numeric_version() -> None: + errs = _ota_downgrade_protection_errors("1.0-beta", signed_ota_enabled=True) + assert len(errs) == 1 + assert "dotted-numeric" in str(errs[0]) + + +def test_downgrade_protection_requires_signed_ota() -> None: + errs = _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=False) + assert len(errs) == 1 + assert "signed_ota_verification" in str(errs[0]) + + +def test_downgrade_protection_reports_all_unmet_requirements() -> None: + # No project version and no signing -> two distinct errors. + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) + assert len(errs) == 2 diff --git a/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml new file mode 100644 index 00000000000..5d6ab455acd --- /dev/null +++ b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml @@ -0,0 +1,22 @@ +esphome: + project: + name: esphome.downgrade_test + version: "1.2.3" + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + enable_ota_downgrade_protection: true + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key.pem + signing_scheme: rsa3072 + +# wifi + ota so the IDF OTA backend compiles with USE_OTA_DOWNGRADE_PROTECTION. +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/md5/__init__.py b/tests/components/md5/__init__.py new file mode 100644 index 00000000000..cf4ad47363b --- /dev/null +++ b/tests/components/md5/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # md5's to_code calls cg.add_define("USE_MD5"), which gates md5.h. C++ unit + # test builds that pull md5 in transitively (e.g. ota's host backend, which + # has an md5::MD5Digest member) need that define, otherwise md5.h compiles to + # nothing and the dependent headers fail to find md5::MD5Digest. + manifest.enable_codegen() diff --git a/tests/components/ota/test_version_compare.cpp b/tests/components/ota/test_version_compare.cpp new file mode 100644 index 00000000000..4072a457925 --- /dev/null +++ b/tests/components/ota/test_version_compare.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +// version_is_older(candidate, reference) == true means candidate is a downgrade +// and should be rejected. + +TEST(VersionIsOlder, PatchOlder) { + EXPECT_TRUE(version_is_older("1.2.3", "1.2.4")); + EXPECT_FALSE(version_is_older("1.2.4", "1.2.3")); +} + +TEST(VersionIsOlder, NumericNotLexical) { + // "1.10.0" is newer than "1.9.0" even though '1' < '9' lexically. + EXPECT_TRUE(version_is_older("1.9.0", "1.10.0")); + EXPECT_FALSE(version_is_older("1.10.0", "1.9.0")); +} + +TEST(VersionIsOlder, MajorMinor) { + EXPECT_TRUE(version_is_older("1.9.9", "2.0.0")); + EXPECT_TRUE(version_is_older("1.2.9", "1.3.0")); + EXPECT_FALSE(version_is_older("2.0.0", "1.9.9")); +} + +TEST(VersionIsOlder, EqualVersionsAllowed) { + // Re-flashing the same version must be permitted. + EXPECT_FALSE(version_is_older("1.2.3", "1.2.3")); + EXPECT_FALSE(version_is_older("2024.1.0", "2024.1.0")); +} + +TEST(VersionIsOlder, DifferingComponentCounts) { + // Missing trailing components count as 0. + EXPECT_FALSE(version_is_older("1.2", "1.2.0")); + EXPECT_FALSE(version_is_older("1.2.0", "1.2")); + EXPECT_TRUE(version_is_older("1.2", "1.2.1")); + EXPECT_FALSE(version_is_older("1.2.1", "1.2")); +} + +TEST(VersionIsOlder, CalendarVersions) { + EXPECT_TRUE(version_is_older("2024.12.0", "2025.1.0")); + EXPECT_FALSE(version_is_older("2025.1.0", "2024.12.0")); +} + +TEST(VersionIsOlder, NullInputsAreSafe) { + EXPECT_FALSE(version_is_older(nullptr, "1.2.3")); + EXPECT_FALSE(version_is_older("1.2.3", nullptr)); + EXPECT_FALSE(version_is_older(nullptr, nullptr)); +} + +} // namespace esphome::ota::testing From a10e005bb44cebb933b3c5f0263006f950d8ff15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:54:44 -0500 Subject: [PATCH 303/343] [esp8266] Strip lwIP glue dhcp stub message strings from DRAM (#17395) --- esphome/components/esp8266/__init__.py | 6 ++++ .../components/esp8266/lwip_glue_stubs.cpp | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 esphome/components/esp8266/lwip_glue_stubs.cpp diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index b658feb76aa..ab742db0656 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -332,6 +332,12 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap the lwIP2 glue's do-nothing dhcp_cleanup()/dhcp_release() stubs so the + # linker can drop their "STUB: ..." message strings from DRAM. + # See lwip_glue_stubs.cpp for implementation. + for symbol in ("dhcp_cleanup", "dhcp_release"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. diff --git a/esphome/components/esp8266/lwip_glue_stubs.cpp b/esphome/components/esp8266/lwip_glue_stubs.cpp new file mode 100644 index 00000000000..a86c8d75a2f --- /dev/null +++ b/esphome/components/esp8266/lwip_glue_stubs.cpp @@ -0,0 +1,35 @@ +/* + * Linker wrap stubs for the lwIP2 glue's dead DHCP entry points. + * + * The ESP8266 SDK blobs call dhcp_cleanup() and dhcp_release() when the + * station leaves an access point (cnx_sta_leave, wifi_station_dhcpc_stop). + * In the prebuilt lwIP2 glue (liblwip2-*.a, glue-esp/lwip-esp.c) these are + * stubs whose only effect is printing "STUB: dhcp_cleanup" and + * "STUB: dhcp_release"; the real DHCP teardown happens through lwIP2's + * renamed dhcp_cleanup_LWIP2()/dhcp_release_LWIP2() functions. + * + * On ESP8266 .rodata lives in DRAM, so those message strings waste scarce + * RAM. Wrapping the stubs with silent equivalents lets the linker garbage + * collect the glue stub bodies together with their strings. + * + * Saves 38 bytes of RAM and removes the "STUB:" log noise on Wi-Fi + * disconnect. Behavior is otherwise unchanged. + */ + +#if defined(USE_ESP8266) + +namespace esphome::esp8266 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +// The callers are closed-source SDK blobs; the netif argument is unused. +void __wrap_dhcp_cleanup(void * /*netif*/) {} + +// The glue stub returns ERR_ABRT (-8; lwIP 1.4 err_t is a signed char). +signed char __wrap_dhcp_release(void * /*netif*/) { return -8; } + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP8266 From 902cf6a67967ce8bf03ab90ca646712666778c30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:54:53 -0500 Subject: [PATCH 304/343] [analyze_memory] Report aliased RAM symbols once in the RAM strings report (#17397) --- esphome/analyze_memory/ram_strings.py | 44 +++++-- .../analyze_memory/test_ram_strings.py | 123 ++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/analyze_memory/test_ram_strings.py diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py index fbcbeeca61a..03da86de94c 100644 --- a/esphome/analyze_memory/ram_strings.py +++ b/esphome/analyze_memory/ram_strings.py @@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266. from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from pathlib import Path import re @@ -65,6 +65,7 @@ class RamSymbol: size: int section: str demangled: str = "" # Demangled name, set after batch demangling + aliases: list[str] = field(default_factory=list) # Other names at same address class RamStringsAnalyzer: @@ -235,6 +236,11 @@ class RamStringsAnalyzer: except (subprocess.CalledProcessError, FileNotFoundError): return + # Track symbols by address so aliases (multiple names for the same + # object, e.g. the newlib __lock___* mutexes that all alias one + # StaticSemaphore_t) are reported once instead of once per name. + symbols_by_addr: dict[int, RamSymbol] = {} + for line in output.split("\n"): parts = line.split() if len(parts) < 4: @@ -253,6 +259,18 @@ class RamStringsAnalyzer: if sym_type not in DATA_SYMBOL_TYPES: continue + if (existing := symbols_by_addr.get(addr)) is not None: + # Prefer a global (uppercase type) name as the primary so + # nm output order can't hide it behind a local alias. + if sym_type.isupper() and existing.sym_type.islower(): + existing.aliases.append(existing.name) + existing.name = name + existing.sym_type = sym_type + else: + existing.aliases.append(name) + existing.size = max(existing.size, size) + continue + # Check if symbol is in a RAM section for section_name in self.ram_sections: if section_name not in self.sections: @@ -260,15 +278,15 @@ class RamStringsAnalyzer: section = self.sections[section_name] if section.address <= addr < section.address + section.size: - self.ram_symbols.append( - RamSymbol( - name=name, - sym_type=sym_type, - address=addr, - size=size, - section=section_name, - ) + symbol = RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, ) + symbols_by_addr[addr] = symbol + self.ram_symbols.append(symbol) break def _demangle_symbols(self) -> None: @@ -436,7 +454,13 @@ class RamStringsAnalyzer: for symbol in largest_symbols: # Use demangled name if available, otherwise raw name display_name = symbol.demangled or symbol.name - name_display = display_name[:49] if len(display_name) > 49 else display_name + # Truncate the name, not the alias note, so merged aliases stay + # visible even for long demangled C++ names. + alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else "" + max_name_len = 49 - len(alias_note) + if len(display_name) > max_name_len: + display_name = display_name[:max_name_len] + name_display = display_name + alias_note lines.append( f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" ) diff --git a/tests/unit_tests/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py new file mode 100644 index 00000000000..dda793a7f12 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -0,0 +1,123 @@ +"""Tests for RAM symbol analysis in the RAM strings analyzer.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo + +# nm -S --size-sort output with the newlib lock mutexes: nine global +# symbols that are all aliases of two local StaticSemaphore_t objects. +NM_OUTPUT_WITH_ALIASES = """\ +3ffb4400 00000010 B small_symbol +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___env_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +3ffb43c8 00000054 B __lock___sfp_recursive_mutex +3ffb43c8 00000054 B __lock___sinit_recursive_mutex +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb441c 00000054 B __lock___arc4random_mutex +3ffb441c 00000054 B __lock___at_quick_exit_mutex +3ffb441c 00000054 B __lock___dd_hash_mutex +3ffb441c 00000054 B __lock___tz_mutex +3ffb441c 00000054 b s_common_mutex +""" + + +def _make_analyzer(tmp_path) -> RamStringsAnalyzer: + """Create an analyzer with a dummy ELF and a .dram0.bss section.""" + elf = tmp_path / "firmware.elf" + elf.write_bytes(b"\x7fELF") + analyzer = RamStringsAnalyzer(str(elf), platform="esp32") + analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000) + return analyzer + + +def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None: + """Run _analyze_symbols with mocked nm output.""" + with ( + patch( + "esphome.analyze_memory.ram_strings.find_tool", + return_value="nm", + ), + patch.object(analyzer, "_run_command", return_value=nm_output), + ): + analyzer._analyze_symbols() + + +def test_aliased_symbols_counted_once(tmp_path: Path) -> None: + """Symbols sharing an address are one object, not one per name.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + # Three distinct addresses, so three symbols + assert len(analyzer.ram_symbols) == 3 + total = sum(s.size for s in analyzer.ram_symbols) + assert total == 0x10 + 0x54 + 0x54 + + +def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None: + """Extra names at the same address are kept as aliases.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + by_addr = {s.address: s for s in analyzer.ram_symbols} + assert len(by_addr[0x3FFB43C8].aliases) == 5 + assert len(by_addr[0x3FFB441C].aliases) == 4 + assert by_addr[0x3FFB4400].aliases == [] + assert "s_common_mutex" in by_addr[0x3FFB441C].aliases + + +def test_alias_count_shown_in_report(tmp_path: Path) -> None: + """The large symbols table notes how many aliases were merged.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + report = analyzer.generate_report() + assert "(+5 aliases)" in report + assert "(+4 aliases)" in report + # Each lock name appears at most once in the report + assert report.count("__lock___") == 2 + + +def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None: + """A global name becomes the primary even when nm lists a local first.""" + analyzer = _make_analyzer(tmp_path) + nm_output = """\ +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +""" + _run_symbol_analysis(analyzer, nm_output) + + (symbol,) = analyzer.ram_symbols + assert symbol.name == "__lock___atexit_recursive_mutex" + assert symbol.sym_type == "B" + assert sorted(symbol.aliases) == [ + "__lock___malloc_recursive_mutex", + "s_common_recursive_mutex", + ] + + +def test_alias_note_survives_name_truncation(tmp_path: Path) -> None: + """Long names are truncated but the alias note is kept intact.""" + analyzer = _make_analyzer(tmp_path) + long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far" + nm_output = f"""\ +3ffb43c8 00000054 B {long_name} +3ffb43c8 00000054 B other_name +""" + _run_symbol_analysis(analyzer, nm_output) + + report = analyzer.generate_report() + row = next(line for line in report.splitlines() if "(+1 aliases)" in line) + name_column = row[:50].rstrip() + assert name_column.endswith("(+1 aliases)") + assert name_column.startswith("a_very_long_symbol_name") + + +def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None: + """Symbols outside known RAM sections are ignored entirely.""" + analyzer = _make_analyzer(tmp_path) + nm_output = "40080000 00000100 B not_in_ram\n" + _run_symbol_analysis(analyzer, nm_output) + assert analyzer.ram_symbols == [] From a36c3063b2c8e302092f4440070b7d194e5f8c4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:55:02 -0500 Subject: [PATCH 305/343] [web_server] Use known message length in SSE send path (#17400) --- esphome/components/web_server/web_server.cpp | 23 +++++++------- esphome/components/web_server/web_server.h | 6 ++-- .../web_server_idf/web_server_idf.cpp | 30 +++++++++---------- .../web_server_idf/web_server_idf.h | 6 ++-- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cdb8544fbb4..96195a8270e 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -257,8 +257,10 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * } // used for logs plus the initial ping/config -void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { + // ESPAsyncWebServer's send() only accepts null-terminated strings + (void) message_len; this->send(message, event, id, reconnect); } @@ -279,10 +281,10 @@ void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const ch } } -void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { for (DeferredUpdateEventSource *dues : *this) { - dues->try_send_nodefer(message, event, id, reconnect); + dues->try_send_nodefer(message, message_len, event, id, reconnect); } } @@ -304,7 +306,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource // Configure reconnect timeout and send config // this should always go through since the AsyncEventSourceClient event queue is empty on connect auto message = ws->get_config_json(); - source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + source->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -315,7 +317,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource auto group_msg = builder.serialize(); // up to 31 groups should be able to be queued initially without defer - source->try_send_nodefer(group_msg.c_str(), "sorting_group"); + source->try_send_nodefer(group_msg.c_str(), group_msg.size(), "sorting_group"); } #endif @@ -395,8 +397,8 @@ void WebServer::setup() { return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); - buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); - this->events_.try_send_nodefer(buf, "ping", millis(), 30000); + size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); + this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); } void WebServer::loop() { @@ -414,8 +416,7 @@ void WebServer::loop() { void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); + this->events_.try_send_nodefer(message, message_len, "log", millis()); } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index e4defdbd9a4..42182fe5107 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -160,7 +160,8 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); }; class DeferredUpdateEventSourceList final : public std::list { @@ -173,7 +174,8 @@ class DeferredUpdateEventSourceList final : public std::listsessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->try_send_nodefer(message, event, id, reconnect); + ses->try_send_nodefer(message, message_len, event, id, reconnect); } } } @@ -600,7 +601,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -612,7 +613,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), "sorting_group"); + this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); } #endif @@ -647,7 +648,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), "state")) { + if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -718,7 +719,7 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.advance(); } -bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, +bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { if (this->fd_.load() == 0) { return false; @@ -764,19 +765,18 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char // Fast path: check if message contains any newlines at all // Most SSE messages (JSON state updates) have no newlines - const char *first_n = strchr(message, '\n'); - const char *first_r = strchr(message, '\r'); + const char *first_n = static_cast(memchr(message, '\n', message_len)); + const char *first_r = static_cast(memchr(message, '\r', message_len)); if (first_n == nullptr && first_r == nullptr) { // No newlines - fast path (most common case) event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); + event_buffer_.append(message, message_len); event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator } else { // Has newlines - handle multi-line message const char *line_start = message; - size_t msg_len = strlen(message); - const char *msg_end = message + msg_len; + const char *msg_end = message + message_len; // Reuse the first search results const char *next_n = first_n; @@ -789,7 +789,7 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char if (next_n == nullptr && next_r == nullptr) { // No more line breaks - output remaining text as final line event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start); + event_buffer_.append(line_start, msg_end - line_start); event_buffer_.append(CRLF_STR, CRLF_LEN); break; } @@ -828,8 +828,8 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char } // Search for next newlines only in remaining string - next_n = strchr(line_start, '\n'); - next_r = strchr(line_start, '\r'); + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); } // Terminate message with blank line @@ -884,7 +884,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e deq_push_back_with_dedup_(source, message_generator); } else { auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), "state")) { + if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c622d53e893..c631cd14531 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -291,7 +291,8 @@ class AsyncEventSourceResponse { friend class AsyncEventSource; public: - bool try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); void loop(); @@ -343,7 +344,8 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); From e8d37e5bd362bb49710dd90485b45200b6efa31c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 19:55:12 -0500 Subject: [PATCH 306/343] [libretiny] Use standard logger tag names (#17431) --- esphome/components/libretiny/gpio_arduino.cpp | 2 +- esphome/components/libretiny/lt_component.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index 1af0dce16d4..b1a37cb2258 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.gpio"; +static const char *const TAG = "libretiny.gpio"; static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index c01661b3a68..9bbbd66be47 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -6,7 +6,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.component"; +static const char *const TAG = "libretiny"; void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, From ad7c980c4b46b1464f68bea405e94b412f02bd2c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:16:29 +1000 Subject: [PATCH 307/343] [lvgl] Continue activity while display busy (#17374) --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvgl_esphome.cpp | 60 ++++++++++++++--------- esphome/components/lvgl/lvgl_esphome.h | 20 +++++++- tests/components/lvgl/lvgl-package.yaml | 16 +----- tests/components/lvgl/test.esp32-idf.yaml | 5 +- 6 files changed, 64 insertions(+), 41 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9137412abe5..08369927b92 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -414,6 +414,8 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if refr_time := config.get(df.CONF_REFRESH_INTERVAL): + cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) lv_scr_act = get_screen_active(lv_component) @@ -598,6 +600,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(df.CONF_REFRESH_INTERVAL): cv.positive_time_period_milliseconds, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(CONF_ROTATION): validate_rotation, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index d9be881a7fc..53499503d4f 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -764,6 +764,7 @@ CONF_PLACEHOLDER_TEXT = "placeholder_text" CONF_POINTS = "points" CONF_PREVIOUS = "previous" CONF_RADIUS = "radius" +CONF_REFRESH_INTERVAL = "refresh_interval" CONF_REPEAT_COUNT = "repeat_count" CONF_RECOLOR = "recolor" CONF_RESUME_ON_INPUT = "resume_on_input" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 15c2d238be1..1db5992389d 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -401,7 +401,10 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { } void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) { - if (!this->is_paused()) { + // no guard here for display busy, since LVGL will not call flush_cb until the refresh timer fires, + // and while the display is busy this is reset to 5 minutes. If that expires and the display is still + // busy there are bigger problems. + if (!this->paused_) { auto now = millis(); this->draw_buffer_(area, reinterpret_cast(color_p)); ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", (int) area->x1, (int) area->y1, @@ -620,20 +623,20 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { void LvglComponent::draw_end_() { if (this->draw_end_callback_ != nullptr) this->draw_end_callback_->trigger(); + // Only reachable once the display is idle again: while busy, the display's refr_timer_ is + // paused (see loop()), so LVGL never renders/flushes and this event never fires. if (this->update_when_display_idle_) { for (auto *disp : this->displays_) disp->update(); } } -bool LvglComponent::is_paused() const { - if (this->paused_) - return true; - if (this->update_when_display_idle_) { - for (auto *disp : this->displays_) { - if (!disp->is_idle()) - return true; - } +bool LvglComponent::displays_busy_() const { + if (!this->update_when_display_idle_) + return false; + for (auto *disp : this->displays_) { + if (!disp->is_idle()) + return true; } return false; } @@ -777,6 +780,8 @@ void LvglComponent::setup() { if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this); } + this->refr_timer_ = lv_display_get_refr_timer(this->disp_); + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); #if LV_USE_LOG lv_log_register_print_cb([](lv_log_level_t level, const char *buf) { auto next = strchr(buf, ')'); @@ -802,21 +807,32 @@ void LvglComponent::update() { } void LvglComponent::loop() { - if (this->is_paused()) { - if (this->paused_ && this->show_snow_) + if (this->paused_) { + if (this->show_snow_) this->write_random_(); - } else { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - auto now = millis(); - lv_timer_handler(); - auto elapsed = millis() - now; - if (elapsed > 15) { - ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now)); - } -#else - lv_timer_handler(); -#endif + return; } + // Pause/resume the display's own refresh timer to track its busy state. While paused, LVGL + // still keeps track of invalidated areas but won't render or flush them, so nothing needs to + // be discarded or replayed: once resumed, the accumulated areas are simply drawn as normal. + // Input events and other timers keep being processed below regardless of this state. + if (this->update_when_display_idle_) { + bool busy = this->displays_busy_(); + if (busy && !this->refr_timer_paused_) { + this->refr_timer_paused_ = true; + // calling lv_timer_pause() here would be ineffective; LVGL pauses and resumes the timer based on its own internal + // state, which is not aware of the display's busy state. Instead, we extend the timer period to avoid it firing + // while the display is busy. + lv_timer_set_period(this->refr_timer_, 5 * 60 * 1000); + } else if (!busy && this->refr_timer_paused_) { + this->refr_timer_paused_ = false; + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); + // Don't wait for the timer's next natural period: refresh right away now that the + // display is idle again. + lv_timer_ready(this->refr_timer_); + } + } + lv_timer_handler(); } #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8840b0ad30f..dcbf490bce9 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,9 +214,14 @@ class LvglComponent final : public PollingComponent { // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. void set_paused(bool paused, bool show_snow); + void set_refresh_interval(uint32_t period) { + this->refr_timer_period_ = period; + if (this->refr_timer_ != nullptr) + lv_timer_set_period(this->refr_timer_, period); + } - // Returns true if the display is explicitly paused, or a blocking display update is in progress. - bool is_paused() const; + // Returns true if the display has been explicitly paused via set_paused(). + bool is_paused() const { return this->paused_; } // If the display is paused and we have resume_on_input_ set to true, resume the display. void maybe_wakeup() { if (this->paused_ && this->resume_on_input_) { @@ -299,6 +304,9 @@ class LvglComponent final : public PollingComponent { // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case void draw_start_() const { this->draw_start_callback_->trigger(); } + // Returns true if update_when_display_idle is enabled and at least one underlying display + // component is currently busy (e.g. mid-refresh). + bool displays_busy_() const; void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); @@ -316,6 +324,14 @@ class LvglComponent final : public PollingComponent { uint8_t *draw_buf_{}; lv_display_t *disp_{}; + // The display's own periodic refresh timer, effectively paused while the display is busy (see + // displays_busy_()) so LVGL neither renders nor flushes to it, without losing track of + // invalidated areas. Other timers (indev reading, animations, ...) keep running as normal. + lv_timer_t *refr_timer_{}; + // Tracks whether refr_timer_ is currently paused, so loop() can detect the busy -> idle edge + // and kick off an immediate refresh instead of waiting for the timer's next natural period. + bool refr_timer_paused_{}; + uint32_t refr_timer_period_{16}; uint16_t width_{}; uint16_t height_{}; bool paused_{}; diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 7af058e6b87..4f043db7cb1 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -24,20 +24,6 @@ binary_sensor: name: Button A checked widget: button_a state: checked - - platform: lvgl - id: button_checker - name: LVGL button - widget: button_button - state: checked - on_state: - then: - - lvgl.checkbox.update: - id: checkbox_id - state: - checked: !lambda |- - auto y = x; // block inlining of one line return - return y; - - platform: lvgl id: button_presser name: Button pressed @@ -49,6 +35,8 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + update_when_display_idle: true + refresh_interval: 30ms on_pause: - logger.log: LVGL is Paused - lvgl.display.set_rotation: 90 diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 79ea06f16af..d938017fd9c 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -1,7 +1,8 @@ packages: - lvgl: !include lvgl-package.yaml + lvgl_package: !include lvgl-package.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lvgl: !include common.yaml sensor: - platform: rotary_encoder @@ -77,5 +78,3 @@ lvgl: - component.update: tft_display - delay: 60s - lvgl.resume: - -<<: !include common.yaml From 9857d508d95efb7403e882cfccd7fc6053ce4e7e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:57:06 +1000 Subject: [PATCH 308/343] [light] Preserve brightness on turn-off. (#17103) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/light/light_call.cpp | 18 ++++++----- tests/integration/test_light_calls.py | 43 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7b28065e4ea..2b13b40a16c 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -213,17 +213,19 @@ LightColorValues LightCall::validate_() { // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. bool explicit_turn_off_request = this->has_state() && !this->state_; - // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (this->has_brightness() && this->brightness_ == 0.0f) { + // Treat zero brightness as an implicit turn-off when no state was explicitly requested. + if (this->has_brightness() && this->brightness_ == 0.0f && !this->has_state()) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - if (color_mode & ColorCapability::BRIGHTNESS) { - // Reset brightness so the light has nonzero brightness when turned back on. + } + + // Make sure a turn-on makes the light visible: if the resulting brightness would be zero + // (e.g. restored from a brightness=0 turn-off), reset it to full brightness. + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) { + float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness(); + if (brightness == 0.0f) { this->brightness_ = 1.0f; - } else { - // Light doesn't support brightness; clear the flag to avoid a spurious - // "brightness not supported" warning during capability validation. - this->clear_flag_(FLAG_HAS_BRIGHTNESS); + this->set_flag_(FLAG_HAS_BRIGHTNESS); } } diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 0eaf5af91b7..a3a4103f5cd 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -322,6 +322,49 @@ async def test_light_calls( assert state.state is True assert state.brightness == pytest.approx(0.75) + # Test 31: Setting brightness to 0 without an explicit state implicitly turns + # the light off; turning it back on (without an explicit brightness) then + # restores full brightness so the light is visible again. + client.light_command(key=rgbcw_light.key, state=True, brightness=0.5) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.5) + + # Brightness 0 with no explicit state -> implicit turn-off + client.light_command(key=rgbcw_light.key, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + assert state.brightness == pytest.approx(0.0) + # Turning on without an explicit brightness restores it to full brightness + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 31b: An explicit turn-on with brightness 0 still resets to full + # brightness - a turn-on must never leave the light on-but-invisible. This + # is the same path the restore logic exercises (set_state(true) + + # set_brightness(0) from a persisted brightness=0 turn-off). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 32: Turning a light on when it already has nonzero brightness leaves + # the brightness unchanged (the reset only happens when brightness is 0). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.4) + state = await wait_for_state_change(rgbcw_light.key) + assert state.brightness == pytest.approx(0.4) + + client.light_command(key=rgbcw_light.key, state=False) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.4) + # Final cleanup - turn all lights off for light in lights: client.light_command( From 9aed1d2700681390cfe0df5301cb82f4744faaff Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 6 Jul 2026 23:04:38 -0500 Subject: [PATCH 309/343] [esp32] Add NVS encryption (HMAC scheme) (#17004) --- esphome/components/esp32/__init__.py | 62 +++++++++++++++++++ .../esp32/config/nvs_encryption_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 38 ++++++++++++ .../test-nvs_encryption.esp32-s3-idf.yaml | 9 +++ 4 files changed, 119 insertions(+) create mode 100644 tests/component_tests/esp32/config/nvs_encryption_s3.yaml create mode 100644 tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5a7ddb6c762..e8d1fe73c7d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" +CONF_NVS_ENCRYPTION = "nvs_encryption" CONF_RELEASE = "release" CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" CONF_SIGNING_KEY = "signing_key" @@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = { VARIANT_ESP32, } +# NVS encryption (HMAC peripheral scheme) is only available on variants that +# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original +# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral +# should be added here. +NVS_ENCRYPTION_HMAC_VARIANTS = { + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -1349,6 +1365,29 @@ def final_validate(config): "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + variant = config[CONF_VARIANT] + if variant in NVS_ENCRYPTION_HMAC_VARIANTS: + _LOGGER.warning( + "NVS encryption will burn an HMAC key into eFuse key block %d on the " + "first boot of each device. This is PERMANENT and IRREVERSIBLE: " + "the block cannot be erased or reused afterwards. Enabling (or " + "later disabling) encryption also wipes any previously saved " + "preferences once, because the older data can no longer be read.", + nvs_enc[CONF_KEY_ID], + ) + else: + supported = ", ".join( + sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS) + ) + errs.append( + cv.Invalid( + f"NVS encryption (HMAC scheme) is not supported on " + f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). " + f"Supported variants: {supported}.", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION], + ) + ) if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: project = full_config[CONF_ESPHOME].get(CONF_PROJECT) errs.extend( @@ -1609,6 +1648,15 @@ FRAMEWORK_SCHEMA = cv.Schema( ), cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), ), + cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( + { + # eFuse key block (0-5) that stores the HMAC key from + # which the NVS encryption keys are derived. The block is + # written on first boot if empty -- an irreversible + # operation -- so it must be chosen explicitly. + cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5), + } + ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False ): cv.boolean, @@ -2451,6 +2499,20 @@ async def to_code(config): cg.add_define("USE_OTA_SIGNED_VERIFICATION") + # Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are + # derived at runtime from an HMAC key stored in the configured eFuse block + # (no flash encryption required). The HMAC key is generated and burned into + # the eFuse block on first boot if it is empty. With the scheme selected, + # nvs_sec_provider registers it at startup and the default nvs_flash_init() + # (used in esp32/preferences.cpp) transparently performs the secure init, so + # no C++ changes are needed. + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True) + add_idf_sdkconfig_option( + "CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID] + ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( diff --git a/tests/component_tests/esp32/config/nvs_encryption_s3.yaml b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml new file mode 100644 index 00000000000..371f2e28caa --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1b189c63310..d53e119e9f4 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf( r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", id="ignore_efuse_mac_crc_only_on_esp32", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 0}}, + }, + }, + r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]", + id="nvs_encryption_unsupported_on_esp32", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 6}}, + }, + }, + r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", + id="nvs_encryption_key_id_out_of_range", + ), ], ) def test_esp32_configuration_errors( @@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_nvs_encryption_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that nvs_encryption sets the HMAC scheme sdkconfig options.""" + generate_main(component_config_path("nvs_encryption_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True + assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True + assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0 + # The permanent/irreversible eFuse burn is warned about at config time. + assert "PERMANENT and IRREVERSIBLE" in caplog.text + + @pytest.mark.parametrize( ("fixture", "expect_warning"), [ diff --git a/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml new file mode 100644 index 00000000000..ab9001efec6 --- /dev/null +++ b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +<<: !include common.yaml From f823a23ea412be94c87bd0f8204747a25076f6cf Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Jul 2026 07:15:37 +0200 Subject: [PATCH 310/343] [pcm5122] Add analog gain, channel mixing, volume range, standby/powerdown switch, and XSMT enable pin support (#17313) --- esphome/components/pcm5122/audio_dac.py | 51 ++++++++- esphome/components/pcm5122/pcm5122.cpp | 104 +++++++++++++++++- esphome/components/pcm5122/pcm5122.h | 49 ++++++++- esphome/components/pcm5122/switch/__init__.py | 32 ++++++ .../pcm5122/switch/power_switch.cpp | 12 ++ .../components/pcm5122/switch/power_switch.h | 24 ++++ tests/components/pcm5122/common.yaml | 11 ++ 7 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 esphome/components/pcm5122/switch/__init__.py create mode 100644 esphome/components/pcm5122/switch/power_switch.cpp create mode 100644 esphome/components/pcm5122/switch/power_switch.h diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 0017a1ef5a5..c18fb3993e7 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -5,6 +5,7 @@ from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, + CONF_ENABLE_PIN, CONF_ID, CONF_INPUT, CONF_INVERTED, @@ -16,6 +17,11 @@ from esphome.const import ( CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] +CONF_ANALOG_GAIN = "analog_gain" +CONF_CHANNEL_MIX = "channel_mix" +CONF_VOLUME_MIN_DB = "volume_min_db" +CONF_VOLUME_MAX_DB = "volume_max_db" + pcm5122_ns = cg.esphome_ns.namespace("pcm5122") PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) CONF_PCM5122 = "pcm5122" @@ -27,26 +33,60 @@ PCM5122_BITS_PER_SAMPLE_ENUM = { 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, } +pcm5122_analog_gain = pcm5122_ns.enum("PCM5122AnalogGain") +PCM5122_ANALOG_GAIN_ENUM = { + "0db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_0DB, + "-6db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_MINUS_6DB, +} + +pcm5122_channel_mix = pcm5122_ns.enum("PCM5122ChannelMix") +PCM5122_CHANNEL_MIX_ENUM = { + "stereo": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_STEREO, + "left": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_LEFT_ONLY, + "right": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_RIGHT_ONLY, + "swapped": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_SWAPPED, +} + _validate_bits = cv.float_with_unit("bits", "bit") +def _validate_volume_range(config): + if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: + raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") + return config + + PCM5122GPIOPin = pcm5122_ns.class_( "PCM5122GPIOPin", cg.GPIOPin, cg.Parented.template(PCM5122), ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(PCM5122), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) ), + cv.Optional(CONF_ANALOG_GAIN, default="0db"): cv.enum( + PCM5122_ANALOG_GAIN_ENUM, lower=True + ), + cv.Optional(CONF_CHANNEL_MIX, default="stereo"): cv.enum( + PCM5122_CHANNEL_MIX_ENUM, lower=True + ), + cv.Optional(CONF_VOLUME_MIN_DB, default="-52.5dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_VOLUME_MAX_DB, default="0dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x4D)) + .extend(i2c.i2c_device_schema(0x4D)), + _validate_volume_range, ) @@ -96,3 +136,10 @@ async def to_code(config): await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) + cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) + cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) + cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) + if enable_pin_config := config.get(CONF_ENABLE_PIN): + enable_pin = await cg.gpio_pin_expression(enable_pin_config) + cg.add(var.set_enable_pin(enable_pin)) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp index 68bbd50e4f2..d178cb83b88 100644 --- a/esphome/components/pcm5122/pcm5122.cpp +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -10,6 +10,12 @@ namespace esphome::pcm5122 { static const char *const TAG = "pcm5122"; void PCM5122::setup() { + // Hold XSMT low (soft mute asserted) until init completes + if (this->enable_pin_ != nullptr) { + this->enable_pin_->setup(); + this->enable_pin_->digital_write(false); + } + // Select page 0 and verify chip presence via I2C ACK if (!this->select_page_(0)) { ESP_LOGE(TAG, "Write failed"); @@ -51,7 +57,22 @@ void PCM5122::setup() { } this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + if (!this->write_channel_mix_()) { + this->mark_failed(); + return; + } + + if (!this->write_analog_gain_()) { + this->mark_failed(); + return; + } + // PLL reference clock: BCK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->mark_failed(); + return; + } optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); if (!pll_ref.has_value()) { ESP_LOGE(TAG, "Failed to read PLL_REF"); @@ -67,15 +88,40 @@ void PCM5122::setup() { this->mark_failed(); return; } + + // Release XSMT (soft un-mute) now that init has completed + if (this->enable_pin_ != nullptr) { + this->enable_pin_->digital_write(true); + } } void PCM5122::dump_config() { + const char *channel_mix_str; + switch (this->channel_mix_) { + case PCM5122_CHANNEL_MIX_LEFT_ONLY: + channel_mix_str = "left only"; + break; + case PCM5122_CHANNEL_MIX_RIGHT_ONLY: + channel_mix_str = "right only"; + break; + case PCM5122_CHANNEL_MIX_SWAPPED: + channel_mix_str = "swapped"; + break; + default: + channel_mix_str = "stereo"; + break; + } ESP_LOGCONFIG(TAG, "Audio DAC:"); LOG_I2C_DEVICE(this); ESP_LOGCONFIG(TAG, " Bits per sample: %u\n" + " Analog gain: %s\n" + " Channel mix: %s\n" + " Volume range: %.1f dB to %.1f dB\n" " Muted: %s", - this->bits_per_sample_, YESNO(this->is_muted_)); + this->bits_per_sample_, this->analog_gain_ == PCM5122_ANALOG_GAIN_0DB ? "0 dB" : "-6 dB", + channel_mix_str, this->volume_min_db_, this->volume_max_db_, YESNO(this->is_muted_)); + LOG_PIN(" Enable Pin: ", this->enable_pin_); } bool PCM5122::set_mute_off() { @@ -118,11 +164,11 @@ bool PCM5122::write_mute_() { } bool PCM5122::write_volume_() { - // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). - // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFE = -103 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to volume_min_db_, which is not true silence unless set to -103 dB. // Use set_mute_on() for silence. - const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale - const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + const uint8_t dvol_max_volume = static_cast(lroundf(0x30 - this->volume_max_db_ * 2.0f)); + const uint8_t dvol_min_volume = static_cast(lroundf(0x30 - this->volume_min_db_ * 2.0f)); const uint8_t volume_byte = dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); @@ -137,4 +183,52 @@ bool PCM5122::write_volume_() { return true; } +bool PCM5122::write_analog_gain_() { + uint8_t gain_byte = this->analog_gain_; + if (!this->select_page_(1) || !this->write_byte(PCM5122_REG_ANALOG_GAIN, gain_byte)) { + ESP_LOGE(TAG, "Writing analog gain failed"); + return false; + } + return true; +} + +bool PCM5122::write_channel_mix_() { + uint8_t channel_mix_byte = this->channel_mix_; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DAC_DATA_PATH, channel_mix_byte)) { + ESP_LOGE(TAG, "Writing channel mix failed"); + return false; + } + return true; +} + +bool PCM5122::set_standby(bool enable) { + bool prev_standby = this->standby_; + this->standby_ = enable; + if (!this->write_power_control_()) { + this->standby_ = prev_standby; + return false; + } + return true; +} + +bool PCM5122::set_powerdown(bool enable) { + bool prev_powerdown = this->powerdown_; + this->powerdown_ = enable; + if (!this->write_power_control_()) { + this->powerdown_ = prev_powerdown; + return false; + } + return true; +} + +bool PCM5122::write_power_control_() { + uint8_t power_byte = + (this->standby_ ? PCM5122_POWER_CONTROL_RQST : 0) | (this->powerdown_ ? PCM5122_POWER_CONTROL_RQPD : 0); + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_POWER_CONTROL, power_byte)) { + ESP_LOGE(TAG, "Writing power control failed"); + return false; + } + return true; +} + } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index 3c42e4d8d2f..199818b06e2 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -3,6 +3,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/hal.h" namespace esphome::pcm5122 { @@ -10,11 +11,13 @@ namespace esphome::pcm5122 { // Page 0 register addresses static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_POWER_CONTROL = 0x02; static const uint8_t PCM5122_REG_MUTE = 0x03; static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; static const uint8_t PCM5122_REG_PLL_REF = 0x0D; static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DAC_DATA_PATH = 0x2A; static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 @@ -23,6 +26,9 @@ static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; +// Page 1 register addresses +static const uint8_t PCM5122_REG_ANALOG_GAIN = 0x02; + // Register values for init sequence static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) @@ -35,12 +41,33 @@ static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) +// Page 0, Register 2 (Power Control): RQST = standby request, RQPD = powerdown request (§10.5.3) +static const uint8_t PCM5122_POWER_CONTROL_RQST = (1 << 4); +static const uint8_t PCM5122_POWER_CONTROL_RQPD = (1 << 0); + +// Page 1, Register 2 (Analog Gain Control): LAGN/RAGN select 0 dB or -6 dB analog gain (§8.3.5.5) +static const uint8_t PCM5122_ANALOG_GAIN_LAGN = (1 << 4); +static const uint8_t PCM5122_ANALOG_GAIN_RAGN = (1 << 0); + enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_16 = 16, PCM5122_BITS_PER_SAMPLE_24 = 24, PCM5122_BITS_PER_SAMPLE_32 = 32, }; +enum PCM5122AnalogGain : uint8_t { + PCM5122_ANALOG_GAIN_0DB = 0x00, + PCM5122_ANALOG_GAIN_MINUS_6DB = PCM5122_ANALOG_GAIN_LAGN | PCM5122_ANALOG_GAIN_RAGN, +}; + +// Page 0, Register 0x2A (DAC Data Path): AUPL/AUPR select which channel's data feeds each output (§7.4.2.42) +enum PCM5122ChannelMix : uint8_t { + PCM5122_CHANNEL_MIX_STEREO = 0x11, // Left data -> left out, right data -> right out + PCM5122_CHANNEL_MIX_LEFT_ONLY = 0x12, // Left data -> both outputs + PCM5122_CHANNEL_MIX_RIGHT_ONLY = 0x21, // Right data -> both outputs + PCM5122_CHANNEL_MIX_SWAPPED = 0x22, // Left/right outputs swapped +}; + class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; @@ -48,6 +75,11 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c:: float get_setup_priority() const override { return setup_priority::IO; } void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + void set_analog_gain(PCM5122AnalogGain analog_gain) { this->analog_gain_ = analog_gain; } + void set_channel_mix(PCM5122ChannelMix channel_mix) { this->channel_mix_ = channel_mix; } + void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; } + void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; } + void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; } bool set_mute_off() override; bool set_mute_on() override; @@ -56,17 +88,30 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c:: bool is_muted() override; float volume() override; + bool set_standby(bool enable); + bool set_powerdown(bool enable); + friend class PCM5122GPIOPin; protected: bool select_page_(uint8_t page); bool write_mute_(); bool write_volume_(); + bool write_analog_gain_(); + bool write_channel_mix_(); + bool write_power_control_(); - float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) - int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + GPIOPin *enable_pin_{nullptr}; + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + float volume_min_db_{-52.5f}; // Matches the previous hardcoded minimum (0x99) + float volume_max_db_{0.0f}; // Matches the previous hardcoded maximum (0x30) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes bool is_muted_{false}; + bool standby_{false}; + bool powerdown_{false}; PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; + PCM5122AnalogGain analog_gain_{PCM5122_ANALOG_GAIN_0DB}; + PCM5122ChannelMix channel_mix_{PCM5122_CHANNEL_MIX_STEREO}; }; } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py new file mode 100644 index 00000000000..10519da895c --- /dev/null +++ b/esphome/components/pcm5122/switch/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG + +from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns + +PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) + +pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") +PCM5122_POWER_SWITCH_MODE_ENUM = { + "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, + "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, +} + +CONFIG_SCHEMA = switch.switch_schema( + PCM5122PowerSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend( + { + cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122), + cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum( + PCM5122_POWER_SWITCH_MODE_ENUM, lower=True + ), + } +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_parented(var, config[CONF_PCM5122]) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pcm5122/switch/power_switch.cpp b/esphome/components/pcm5122/switch/power_switch.cpp new file mode 100644 index 00000000000..45f0be715d5 --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.cpp @@ -0,0 +1,12 @@ +#include "power_switch.h" + +namespace esphome::pcm5122 { + +void PCM5122PowerSwitch::write_state(bool state) { + bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state) + : this->parent_->set_powerdown(state); + if (ok) + this->publish_state(state); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/power_switch.h b/esphome/components/pcm5122/switch/power_switch.h new file mode 100644 index 00000000000..47d30f1a9f1 --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/switch/switch.h" + +#include "../pcm5122.h" + +namespace esphome::pcm5122 { + +enum PCM5122PowerSwitchMode : uint8_t { + PCM5122_POWER_SWITCH_MODE_STANDBY, + PCM5122_POWER_SWITCH_MODE_POWERDOWN, +}; + +class PCM5122PowerSwitch final : public switch_::Switch, public Parented { + public: + void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; } + + protected: + void write_state(bool state) override; + + PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN}; +}; + +} // namespace esphome::pcm5122 diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml index cf96f574643..a8ae1e69756 100644 --- a/tests/components/pcm5122/common.yaml +++ b/tests/components/pcm5122/common.yaml @@ -4,6 +4,11 @@ audio_dac: i2c_id: i2c_bus address: 0x4D bits_per_sample: 32bit + analog_gain: -6db + channel_mix: swapped + volume_min_db: -60dB + volume_max_db: -3dB + enable_pin: GPIO12 output: - platform: gpio @@ -22,3 +27,9 @@ binary_sensor: number: 4 mode: input: true + +switch: + - platform: pcm5122 + pcm5122: pcm5122_dac + name: PCM5122 Power Down + power_mode: powerdown From 3c2dad67f4b81447f7330aa11a2eae9b454325ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 02:07:34 -0500 Subject: [PATCH 311/343] [network] Fix logged use_address with MAC suffix and build it at runtime (#17432) --- esphome/components/api/api_server.cpp | 3 +- .../components/esphome/ota/ota_esphome.cpp | 3 +- esphome/components/ethernet/__init__.py | 4 +- .../components/ethernet/ethernet_component.h | 4 +- esphome/components/network/__init__.py | 13 ++++ esphome/components/network/util.cpp | 23 ++++++ esphome/components/network/util.h | 31 ++------ esphome/components/openthread/__init__.py | 3 +- esphome/components/openthread/openthread.h | 4 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/components/wifi/__init__.py | 3 +- esphome/components/wifi/wifi_component.h | 4 +- .../fixtures/use_address_runtime.yaml | 8 ++ .../use_address_runtime_mac_suffix.yaml | 9 +++ tests/integration/test_use_address_runtime.py | 73 +++++++++++++++++++ 15 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 tests/integration/fixtures/use_address_runtime.yaml create mode 100644 tests/integration/fixtures/use_address_runtime_mac_suffix.yaml create mode 100644 tests/integration/test_use_address_runtime.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ddd03ace4ac..efdeb6991b1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -240,12 +240,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { } void APIServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Server:\n" " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); + network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index db4a2015a78..cab725f704a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -94,11 +94,12 @@ void ESPHomeOTAComponent::setup() { } void ESPHomeOTAComponent::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" " Version: %d", - network::get_use_address(), this->port_, USE_OTA_VERSION); + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index dc4cbda45c5..03fba7164d2 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,7 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import ip_address_literal +from esphome.components.network import add_use_address, ip_address_literal from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -543,7 +543,7 @@ async def to_code(config): await _to_code_rp2040(var, config) cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 16f09a45f0d..71603517270 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -145,6 +145,8 @@ class EthernetComponent final : public Component { network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); @@ -346,7 +348,7 @@ class EthernetComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 616a1892265..b7dfb8d6d2b 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -59,6 +59,19 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj: return IPAddress(str(ip)) +def add_use_address(var: cg.MockObj, use_address: str) -> None: + """Generate a set_use_address() call only when the address must be baked in. + + The default ".local" is not stored in the firmware; it is rebuilt at + runtime from the device name (see network::get_use_address_to()), which also + picks up the MAC suffix when name_add_mac_suffix is enabled. A compile-time + string could never include that suffix, so baking it in would log the wrong + address. + """ + if use_address != f"{CORE.name}.local": + cg.add(var.set_use_address(use_address)) + + def require_high_performance_networking() -> None: """Request high performance networking for network and WiFi. diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 79ddd3844c5..ae250c6a1f6 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -1,5 +1,7 @@ #include "util.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_NETWORK namespace esphome::network { @@ -20,6 +22,27 @@ bool is_disabled() { return false; } +const char *get_use_address_to(std::span buf) { + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + const char *addr = nullptr; +#if defined(USE_ETHERNET) + addr = ethernet::global_eth_component->get_use_address(); +#elif defined(USE_MODEM) + addr = modem::global_modem_component->get_use_address(); +#elif defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_OPENTHREAD) + addr = openthread::global_openthread_component->get_use_address(); +#endif + if (addr != nullptr && addr[0] != '\0') + return addr; + // No explicit use_address configured: the address is the runtime device name + // (which includes the MAC suffix when name_add_mac_suffix is enabled) plus ".local" + const auto &name = App.get_name(); + make_name_with_suffix_to(buf.data(), buf.size(), name.c_str(), name.size(), '.', "local", 5); + return buf.data(); +} + network::IPAddresses get_ip_addresses() { #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index e4e8a01f8cb..17a2ff0977c 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK +#include #include #include "esphome/core/helpers.h" #include "ip_address.h" @@ -53,30 +54,12 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { /// Return whether the network is disabled (only wifi for now) bool is_disabled(); -/// Get the active network hostname -ESPHOME_ALWAYS_INLINE inline const char *get_use_address() { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined -#ifdef USE_ETHERNET - return ethernet::global_eth_component->get_use_address(); -#endif - -#ifdef USE_MODEM - return modem::global_modem_component->get_use_address(); -#endif - -#ifdef USE_WIFI - return wifi::global_wifi_component->get_use_address(); -#endif - -#ifdef USE_OPENTHREAD - return openthread::global_openthread_component->get_use_address(); -#endif - -#if !defined(USE_ETHERNET) && !defined(USE_MODEM) && !defined(USE_WIFI) && !defined(USE_OPENTHREAD) - // Fallback when no network component is defined (e.g., host platform) - return ""; -#endif -} +/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator +static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; +/// Get the active network address for logging. Returns the explicitly configured +/// use_address when one was set, otherwise formats ".local" from the runtime +/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). +const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); } // namespace esphome::network diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index b54fe2b2180..4018ad81e7b 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.network import add_use_address from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -288,7 +289,7 @@ async def to_code(config): enable_mdns_storage() ot = cg.new_Pvariable(config[CONF_ID]) - cg.add(ot.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index eb48d8a74ad..b4654af21f6 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -39,6 +39,8 @@ class OpenThreadComponent final : public Component { void on_factory_reset(std::function callback); void defer_factory_reset_external_callback(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD @@ -76,7 +78,7 @@ class OpenThreadComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 96195a8270e..c8f66755bca 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -421,10 +421,11 @@ void WebServer::on_log(uint8_t level, const char *tag, const char *message, size #endif void WebServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Web Server:\n" " Address: %s:%u", - network::get_use_address(), this->base_->get_port()); + network::get_use_address_to(addr_buf), this->base_->get_port()); } float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f; } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index af600647c1f..dc5c8be4d7d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( request_wifi, ) from esphome.components.network import ( + add_use_address, has_high_performance_networking, ip_address_literal, ) @@ -585,7 +586,7 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication has_eap = False diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0db85c4d758..23b75585648 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -501,6 +501,8 @@ class WiFiComponent final : public Component { network::IPAddress get_dns_address(int num); network::IPAddresses get_ip_addresses(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } @@ -996,7 +998,7 @@ class WiFiComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/tests/integration/fixtures/use_address_runtime.yaml b/tests/integration/fixtures/use_address_runtime.yaml new file mode 100644 index 00000000000..29f3369285c --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime.yaml @@ -0,0 +1,8 @@ +esphome: + name: use-address-runtime + +host: + +api: + +logger: diff --git a/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml new file mode 100644 index 00000000000..9785724cd5e --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml @@ -0,0 +1,9 @@ +esphome: + name: use-address-mac + name_add_mac_suffix: true + +host: + +api: + +logger: diff --git a/tests/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py new file mode 100644 index 00000000000..a4cbbb9c5f3 --- /dev/null +++ b/tests/integration/test_use_address_runtime.py @@ -0,0 +1,73 @@ +"""Integration tests for the runtime-built use_address. + +The default ".local" address is no longer stored as a compile-time string; +it is built at runtime from the device name. This also fixes the logged address +when name_add_mac_suffix is enabled: the baked string used to miss the MAC +suffix, so it never matched the actual mDNS hostname. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +@pytest.mark.asyncio +async def test_use_address_runtime( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The API dump_config logs ".local" built from the device name.""" + address_seen = asyncio.Event() + + def check_output(line: str) -> None: + if "Address: use-address-runtime.local:" in line: + address_seen.set() + + 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 + assert device_info.name == "use-address-runtime" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail("Did not log 'Address: use-address-runtime.local:'") + + +@pytest.mark.asyncio +async def test_use_address_runtime_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With name_add_mac_suffix the logged address includes the MAC suffix.""" + address_seen = asyncio.Event() + expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:" + + def check_output(line: str) -> None: + if expected in line: + address_seen.set() + + 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 + assert device_info.name == f"use-address-mac-{MAC_SUFFIX}" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Did not log '{expected}'") From 40c3a4320f1a44c18cd9f3d883ecbdf152383d89 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:44:44 +0200 Subject: [PATCH 312/343] [core] add const for litre per hour (#17389) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/kamstrup_kmp/sensor.py | 2 +- esphome/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index 134ac245bf3..75ec432ad9f 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_KELVIN, UNIT_KILOWATT, + UNIT_LITRE_PER_HOUR, ) CODEOWNERS = ["@cfeenstra1024"] @@ -37,7 +38,6 @@ CONF_TEMP2 = "temp2" CONF_TEMP_DIFF = "temp_diff" UNIT_GIGA_JOULE = "GJ" -UNIT_LITRE_PER_HOUR = "l/h" # Note: The sensor units are set automatically based un the received data from the meter CONFIG_SCHEMA = ( diff --git a/esphome/const.py b/esphome/const.py index 16d11d3a18a..988134fa467 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1255,6 +1255,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh" UNIT_KILOWATT = "kW" UNIT_KILOWATT_HOURS = "kWh" UNIT_LITRE = "L" +UNIT_LITRE_PER_HOUR = "L/h" UNIT_LITRE_PER_SECOND = "L/s" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" From af4a6e7ec3d5ec05139f7f3df2d6475d5600dadb Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Tue, 7 Jul 2026 13:55:49 +0200 Subject: [PATCH 313/343] [usb_uart] Fix FTDI RX data stall / corruption and input restart reliability (#17348) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 --- esphome/components/usb_uart/ft23xx.cpp | 52 +++++++++++++++++------- esphome/components/usb_uart/usb_uart.cpp | 5 +++ esphome/components/usb_uart/usb_uart.h | 9 +++- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 2e8ff8bcb57..25e4cc524fe 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -3,6 +3,7 @@ #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" @@ -396,7 +397,14 @@ int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { } void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { - if (!channel->initialised_.load() || channel->input_started_.load()) + if (!channel->initialised_.load()) + return; + + // Use compare_exchange_strong to avoid a check-then-act race: start_input() is called + // from both the USB task (self-restart on success) and the main loop (backpressure + // restart), so a plain load()/store() pair can let both threads submit a transfer. + auto started = false; + if (!channel->input_started_.compare_exchange_strong(started, true)) return; const auto *ep = channel->cdc_dev_.in_ep; @@ -408,39 +416,55 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { return; } + // FTDI prepends a 2-byte modem/line status header to every bulk IN packet. size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; if (uart_data_len > 0) { ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); if (!channel->dummy_receiver_) { - // Copy the entire received UART payload into the ring buffer in one - // operation to avoid per-byte overhead and reduce the chance of - // heap activity in hot paths. - channel->input_buffer_.push(status.data + 2, uart_data_len); + UsbDataChunk *chunk = this->chunk_pool_.allocate(); + if (chunk == nullptr) { + this->usb_data_queue_.increment_dropped_count(); + channel->input_started_.store(false); + // Queue is full — wake the main loop to drain it, then let read_array() + // retrigger start_input() rather than spinning here in the USB task. + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + return; + } + // Strip the 2-byte FTDI header before queuing. + memcpy(chunk->data, status.data + 2, uart_data_len); + chunk->length = static_cast(uart_data_len); + chunk->channel = channel; + this->usb_data_queue_.push(chunk); #ifdef USE_UART_DEBUGGER if (channel->debug_) { - // Debug path creates a temporary vector for logging only; this is - // acceptable because debug mode is opt-in and not used in release. uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', channel->debug_prefix_); } #endif + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); } - } else { + } else if (status.data_len >= 2) { ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], channel->index_); } channel->input_started_.store(false); - if (channel->dummy_receiver_ || - channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { - this->start_input(channel); - } + this->start_input(channel); }; - channel->input_started_.store(true); - this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); + if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) { + ESP_LOGE(TAG, "RX transfer submission failed for ep=0x%02X", ep->bEndpointAddress); + channel->input_started_.store(false); + } +} + +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { + ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); + channel->input_buffer_.clear(); } void USBUartTypeFT23XX::enable_channels() { diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index b8749b6a762..a995e93e15e 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -228,6 +228,11 @@ void USBUartComponent::loop() { } #endif + // If there is not enough space for the full chunk, let the device subclass + // handle it (e.g. FTDI clears the buffer to prevent mid-telegram corruption). + if (channel->input_buffer_.get_free_space() < chunk->length) { + this->on_rx_overflow(channel); + } // Push data to ring buffer (now safe in main loop) channel->input_buffer_.push(chunk->data, chunk->length); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a3501fc8cf8..6d60809b386 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -192,9 +192,13 @@ class USBUartComponent : public usb_host::USBClient { void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } - void start_input(USBUartChannel *channel); + virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. + // Default is a no-op; override in device-specific subclasses that need resync on overflow. + virtual void on_rx_overflow(USBUartChannel *channel) {} + // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; @@ -248,7 +252,8 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel); + void start_input(USBUartChannel *channel) override; + void on_rx_overflow(USBUartChannel *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; From 76ee3fe8875764bc6755e6dba413254fec9b33c3 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 7 Jul 2026 14:56:48 +0200 Subject: [PATCH 314/343] [audio_file] Accept mp1/mp2 puremagic detections as MP3 (#17436) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/audio_file/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index 53193c80083..d59ed7411a4 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"] - elif file_type in ("mp3", "mpeg", "mpga"): + elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"): + # With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2". + # Treat those labels as MP3 so we still pick the MP3 decoder. media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"] elif file_type == "flac": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"] From 7f0e826c323772a3e146693d41ea66b68427e556 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:50:36 +1000 Subject: [PATCH 315/343] [lvgl] Add paused option to suppress updates on boot (#16973) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/defines.py | 1 + .../lvgl/config/not_paused.yaml | 26 ++++++++++++++ tests/component_tests/lvgl/config/paused.yaml | 27 ++++++++++++++ tests/component_tests/lvgl/test_paused.py | 35 +++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 1 + 6 files changed, 93 insertions(+) create mode 100644 tests/component_tests/lvgl/config/not_paused.yaml create mode 100644 tests/component_tests/lvgl/config/paused.yaml create mode 100644 tests/component_tests/lvgl/test_paused.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 08369927b92..ecc4b0a7779 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -414,6 +414,8 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if paused := config[df.CONF_PAUSED]: + cg.add(lv_component.set_paused(paused, False)) if refr_time := config.get(df.CONF_REFRESH_INTERVAL): cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) @@ -645,6 +647,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 53499503d4f..15e593b3f64 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -758,6 +758,7 @@ CONF_PAD_COLUMN = "pad_column" CONF_PAGE = "page" CONF_PAGE_WRAP = "page_wrap" CONF_PASSWORD_MODE = "password_mode" +CONF_PAUSED = "paused" CONF_PIVOT_X = "pivot_x" CONF_PIVOT_Y = "pivot_y" CONF_PLACEHOLDER_TEXT = "placeholder_text" diff --git a/tests/component_tests/lvgl/config/not_paused.yaml b/tests/component_tests/lvgl/config/not_paused.yaml new file mode 100644 index 00000000000..1dfe8f4ee92 --- /dev/null +++ b/tests/component_tests/lvgl/config/not_paused.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-not-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/config/paused.yaml b/tests/component_tests/lvgl/config/paused.yaml new file mode 100644 index 00000000000..ea747ec75b3 --- /dev/null +++ b/tests/component_tests/lvgl/config/paused.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + paused: true + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/test_paused.py b/tests/component_tests/lvgl/test_paused.py new file mode 100644 index 00000000000..eede17ec193 --- /dev/null +++ b/tests/component_tests/lvgl/test_paused.py @@ -0,0 +1,35 @@ +"""Tests for the LVGL ``paused`` option code generation.""" + +from __future__ import annotations + +import re + +_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);") + + +def _extract_set_paused(main_cpp: str) -> list[str]: + """Return the normalised argument text of every set_paused() call found. + + Whitespace within and around the arguments is collapsed so unrelated + code-generation formatting changes don't break these tests. + """ + return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)] + + +class TestPausedCodeGeneration: + """Verify that the ``paused`` option drives the set_paused() call.""" + + def test_paused_true_generates_set_paused( + self, generate_main, component_config_path + ): + """``paused: true`` emits a set_paused(true, false) call.""" + main_cpp = generate_main(component_config_path("paused.yaml")) + calls = _extract_set_paused(main_cpp) + assert calls == ["true, false"] + + def test_paused_default_omits_set_paused( + self, generate_main, component_config_path + ): + """Without ``paused`` (default false) no set_paused call is generated.""" + main_cpp = generate_main(component_config_path("not_paused.yaml")) + assert _extract_set_paused(main_cpp) == [] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4f043db7cb1..4ec4eb3bd62 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -35,6 +35,7 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + paused: true update_when_display_idle: true refresh_interval: 30ms on_pause: From b4ad0eb86bab936163ab90cb1b1f659f1032c8f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 09:59:18 -0500 Subject: [PATCH 316/343] [esp32_ble] Fix boot loop when the hosted co-processor does not answer BT bring-up (#17429) --- esphome/components/esp32_ble/ble.cpp | 52 +++++++++++++++++++-- esphome/components/esp32_hosted/__init__.py | 2 + 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6bbf0d6a26b..a2d19f1042a 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -9,6 +9,8 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include #else +#include "esphome/components/watchdog/watchdog.h" +#include extern "C" { #include #include @@ -33,6 +35,19 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +// Bringing up the remote BT controller issues synchronous RPCs to the +// co-processor with 5 second response timeouts, and the default task watchdog +// is also 5 seconds. If the co-processor firmware does not answer (for example +// factory firmware without Bluetooth support), the watchdog would reboot the +// device before the RPC could return an error, causing a boot loop. Raise the +// watchdog for the duration of the bring-up so failures surface as error +// returns instead. 60 seconds covers the worst case: transport reconnect +// (up to ~20s), version preflight (1s), controller init/enable (5s each) and +// the bluedroid host bring-up over the hosted HCI transport. +static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; +#endif + // GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_ #define GAP_SCAN_COMPLETE_EVENTS \ case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \ @@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) { // start bt controller @@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() { esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); #else - esp_hosted_connect_to_slave(); // NOLINT + if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT + ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled"); + return false; + } + + // Fast preflight (1 second RPC timeout): verifies the co-processor answers + // RPCs at all before the 5 second timeout BT controller RPCs below, and + // before hosted_hci_bluedroid_open(), which aborts if the transport is down. + esp_hosted_coprocessor_fwver_t fw_ver{}; + if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) { + ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted " + "update component"); + return false; + } + ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); if (esp_hosted_bt_controller_init() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed"); + ESP_LOGE(TAG, + "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } if (esp_hosted_bt_controller_enable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed"); + ESP_LOGE(TAG, + "BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } @@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() { } bool ESP32BLE::ble_dismantle_() { +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + // Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine @@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() { } #else if (esp_hosted_bt_controller_disable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); return false; } if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed"); return false; } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7f420f27d8c..16e9d497821 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] +# esp32_ble raises the task watchdog around the remote BT controller bring-up +AUTO_LOAD = ["watchdog"] CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" From 1913818b1cd2b8a39001ed6d456e1a7b4fa490dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:16 -0500 Subject: [PATCH 317/343] Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 in /.github/actions/restore-python (#17442) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 8ef0bca2ec3..64b1cabea1c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 71b746b6fadac7d51b89cd05f180d4476df2e15c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:26 -0500 Subject: [PATCH 318/343] Bump astral-sh/setup-uv from 8.3.0 to 8.3.1 (#17444) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 721585a44dc..1757959a51f 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fd6a79cb5e..c7e1c67fb6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2efaec4e948..7e0047ee0d0 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 35c7496cd7ed39d28fb4286dd7adfff44c650354 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:38 -0500 Subject: [PATCH 319/343] Bump CodSpeedHQ/action from 4.18.1 to 4.18.2 (#17445) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e1c67fb6e..e08241681b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 + uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 with: run: | . venv/bin/activate From 731e9fda031e5e0f4d1ddf93fad2ff8572cf364b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:12:57 -0400 Subject: [PATCH 320/343] [internal_temperature] Support all ESP32 variants with a temperature sensor (#17438) --- .../internal_temperature_esp32.cpp | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 1c44a9a2380..64fe3707b18 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -3,17 +3,16 @@ #include "esphome/core/log.h" #include "internal_temperature.h" +#include + #if defined(USE_ESP32_VARIANT_ESP32) // there is no official API available on the original ESP32 extern "C" { uint8_t temprature_sens_read(); } -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED #include "driver/temperature_sensor.h" -#endif // USE_ESP32_VARIANT +#endif namespace esphome::internal_temperature { @@ -27,10 +26,7 @@ void InternalTemperatureSensor::update() { ESP_LOGV(TAG, "Raw temperature value: %d", raw); temperature = (raw - 32) / 1.8f; success = (raw != 128); -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { @@ -49,9 +45,7 @@ void InternalTemperatureSensor::update() { } void InternalTemperatureSensor::setup() { -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if SOC_TEMP_SENSOR_SUPPORTED temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); From 731486d9b0fdc23d89a2264745008052c78ffd00 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 7 Jul 2026 17:20:14 -0700 Subject: [PATCH 321/343] [modbus] Finalize unreleased API surface before 2026.7 (#17434) Co-authored-by: Claude Fable 5 --- esphome/components/modbus/modbus.cpp | 34 +++- esphome/components/modbus/modbus.h | 13 +- .../components/modbus/modbus_definitions.h | 2 +- esphome/components/modbus/modbus_helpers.cpp | 21 +-- esphome/components/modbus/modbus_helpers.h | 35 ++-- .../binary_sensor/modbus_binarysensor.cpp | 2 +- .../modbus_controller/modbus_controller.h | 13 +- .../select/modbus_select.cpp | 4 +- .../switch/modbus_switch.cpp | 2 +- .../modbus_server/modbus_server.cpp | 10 +- .../modbus/modbus_client_hub_test.cpp | 178 ++++++++++++++++++ .../components/modbus/modbus_helpers_test.cpp | 13 +- 12 files changed, 272 insertions(+), 55 deletions(-) create mode 100644 tests/components/modbus/modbus_client_hub_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 527d57fcd78..ecb2e4461cb 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -56,8 +56,7 @@ void ModbusClientHub::loop() { (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, this->last_receive_check_ - this->last_send_); - if (wfr.device) - wfr.device->on_modbus_no_response(); + this->notify_no_response_(wfr); this->waiting_for_response_.reset(); } } @@ -278,11 +277,10 @@ void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t funct "ms after last send", address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, this->last_modbus_byte_ - this->last_send_); - // Invalidate the waiting device so it won't process this response. - if (wfr.device) - wfr.device->on_modbus_no_response(); + // Invalidate the device; the entry survives as an interrupted shell so the late response is ignored. + // A retry requested here stays queued behind the shell until the send-wait timeout clears it. + this->notify_no_response_(wfr); wfr.interrupted = true; - wfr.device = nullptr; return; } @@ -564,6 +562,30 @@ void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, Mo } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { + if (wfr.device == nullptr) + return; + const bool retry = wfr.device->on_modbus_no_response(); + // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach + // over the retry request rather than re-queueing a frame that can no longer be routed. + if (retry && wfr.device != nullptr) + this->requeue_waiting_frame_(wfr); + // The old transaction is over either way; never deliver anything else to the device through it. + wfr.device = nullptr; +} + +void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { + const ModbusFrame &frame = wfr.frame; + if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { + ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); + if (wfr.device != nullptr) + wfr.device->on_modbus_not_sent(); + return; + } + // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. + this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); +} + void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { if (pdu_len == 0) { if (device) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index e48c8c298a6..eeba00f6b12 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -108,7 +108,7 @@ class ModbusClientHub : public Modbus { payload, payload_len), device); }; - void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { this->queue_raw_(address, pdu.data(), pdu.size(), device); } void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); @@ -121,6 +121,10 @@ class ModbusClientHub : public Modbus { // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; void send_next_frame_(); + // Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true. + // wfr is the caller's checked reference to waiting_for_response_. + void notify_no_response_(ModbusDeviceCommand &wfr); + void requeue_waiting_frame_(ModbusDeviceCommand &wfr); void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); uint16_t send_wait_time_{2000}; @@ -179,7 +183,10 @@ class ModbusClientDevice { virtual void on_modbus_data(const std::vector &data) {} virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} virtual void on_modbus_not_sent() {} - virtual void on_modbus_no_response() {} + /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. + /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and + /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + virtual bool on_modbus_no_response() { return false; } void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { this->parent_->send_pdu(this->address_, @@ -187,7 +194,7 @@ class ModbusClientDevice { payload, payload_len), this); } - void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_pdu(std::span pdu) { this->parent_->send_pdu(this->address_, pdu, this); } void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } inline void clear_tx_queue_for_address(bool clear_sent = true) { this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index a5bcc1e3fc1..d11748bcd9d 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -82,7 +82,7 @@ static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D // Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) static constexpr uint16_t MIN_FRAME_SIZE = 4; diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 53fa6afacb7..de109606cb6 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -105,8 +105,8 @@ void log_unsupported_value_type(SensorValueType value_type) { ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return) { +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -114,9 +114,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens if (static_cast(offset) > size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), size); - if (error_return) - *error_return = true; - return value; + return std::nullopt; } const size_t required_size = required_payload_size(sensor_value_type); @@ -127,9 +125,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens if (size - offset < required_size) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), size, required_size); - if (error_return) - *error_return = true; - return value; + return std::nullopt; } switch (sensor_value_type) { @@ -179,8 +175,7 @@ int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sens return value; } -int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, - bool *error_return) { +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) { const size_t required_size = required_payload_size(sensor_value_type); if (required_size == 0) { return 0; // RAW/unsupported: nothing to read @@ -189,9 +184,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue if (required_words > count) { ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", static_cast(sensor_value_type), count, required_words); - if (error_return) - *error_return = true; - return 0; + return std::nullopt; } // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the // sign-extension behaviour stays identical to the wire path. @@ -201,7 +194,7 @@ int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValue bytes[i * 2] = static_cast(reg >> 8); bytes[i * 2 + 1] = static_cast(reg & 0xFF); } - return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF, error_return); + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index fef0f915eab..45a13f75826 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -1,8 +1,10 @@ #pragma once +#include +#include +#include #include #include -#include #include "esphome/core/helpers.h" #include "esphome/components/modbus/modbus_definitions.h" @@ -197,11 +199,15 @@ template T get_data(const std::vector &data, size_t buffer_ * @param data modbus response buffer (uint8_t) * @return content of coil register */ -inline bool coil_from_vector(int coil, const std::vector &data) { - auto data_byte = coil / 8; - return (data[data_byte] & (1 << (coil % 8))) > 0; +inline bool bit_from_packed(int bit, std::span data) { + auto data_byte = bit / 8; + return (data[data_byte] & (1 << (bit % 8))) > 0; } +// Remove before 2027.2.0 +ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") +inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask @@ -276,13 +282,21 @@ template void number_to_payload(Container &data, int64_t val * @param bitmask bitmask used for masking and shifting * @return 64-bit number of the payload */ -int64_t payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return = nullptr); +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask); -/** Convert vector response payload to number. */ +/** Convert a response payload span to number; std::nullopt if the payload is too short. */ +inline std::optional payload_to_number(std::span data, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask); +} + +// Remove before 2027.2.0 +ESPDEPRECATED("Use the std::span overload returning std::optional instead. Removed in 2027.2.0", "2026.8.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask, bool *error_return = nullptr) { - return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask, error_return); + uint32_t bitmask) { + // Released behavior: a too-short payload logs an error and decodes to 0. + return payload_to_number(std::span(data), sensor_value_type, offset, bitmask).value_or(0); } /** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. @@ -292,8 +306,7 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @return 64-bit number of the registers */ -int64_t registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type, - bool *error_return = nullptr); +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); /** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. * @param function_code the modbus function code to use. One of: diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 60c19bb66a3..9656013a5f6 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -14,7 +14,7 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 501fadbcf1b..484b59ede30 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -64,9 +64,10 @@ T get_data(const std::vector &data, size_t buffer_offset) { return modbus::helpers::get_data(data, buffer_offset); } -ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0") +// Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed()) +ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { - return modbus::helpers::coil_from_vector(coil, data); + return modbus::helpers::bit_from_packed(coil, data); } template @@ -83,7 +84,8 @@ inline void number_to_payload(std::vector &data, int64_t value, Sensor ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask) { - return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask); + return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) + .value_or(0); } ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") @@ -377,8 +379,9 @@ class ModbusController final : public PollingComponent, public modbus::ModbusCli * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(const std::vector &data, const SensorItem &item) { - int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); +inline float payload_to_float(std::span data, const SensorItem &item) { + int64_t number = + modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 859828f5f68..c650ca7641e 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -8,7 +8,9 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); + int64_t value = modbus::helpers::payload_to_number(std::span(data), this->sensor_value_type, + this->offset, this->bitmask) + .value_or(0); ESP_LOGD(TAG, "New select value %lld from payload", value); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 044ca2f8cc2..c8b3868bdca 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -33,7 +33,7 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 1f787a0b612..4c4e72a086e 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -137,10 +137,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, if (server_register->write_lambda == nullptr) { return false; // unwritable -> ILLEGAL_DATA_ADDRESS } - bool error = false; - registers_to_number(registers.data() + register_offset, registers.size() - register_offset, - server_register->value_type, &error); - if (error) { + if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type) + .has_value()) { precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value return false; } @@ -154,7 +153,8 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, // rejecting the value at runtime -- which cannot be rolled back. if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, - server_register->value_type); + server_register->value_type) + .value_or(0); return server_register->write_lambda(number); })) { ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp new file mode 100644 index 00000000000..d04c4fe10c8 --- /dev/null +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -0,0 +1,178 @@ +#include + +#include +#include + +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Exposes the protected tx queue and waiting-for-response slot so tests can drive the +// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the +// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. +class NoResponseProbeHub : public ModbusClientHub { + public: + size_t queued_frames() const { return this->tx_buffer_.size(); } + const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } + bool waiting() const { return this->waiting_for_response_.has_value(); } + const ModbusDeviceCommand &waiting_command() const { + EXPECT_TRUE(this->waiting_for_response_.has_value()); + return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) + } + + void force_send_front() { + this->waiting_for_response_ = std::move(this->tx_buffer_.front()); + this->tx_buffer_.pop_front(); + } + // Drives the real unexpected-frame branch in process_modbus_server_frame(). + void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) { + this->process_modbus_server_frame(address, function_code, data, len); + } + void timeout_waiting() { + if (this->waiting_for_response_.has_value()) + this->notify_no_response_(*this->waiting_for_response_); + this->waiting_for_response_.reset(); + } +}; + +// A device with a scripted answer to on_modbus_no_response(). +class RetryingDevice : public ModbusClientDevice { + public: + RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + return this->retry_; + } + int no_response_count_{0}; + + protected: + bool retry_{false}; +}; + +// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry. +class ClearingRetryDevice : public ModbusClientDevice { + public: + ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback + return true; // and still requests a retry + } + int no_response_count_{0}; +}; + +constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100 + +StaticVector read_pdu() { + StaticVector pdu; + pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU)); + return pdu; +} + +} // namespace + +// A device that requests a retry gets the frame the hub was holding re-queued on its behalf, +// byte-identical and still routed to the same device. +TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + ASSERT_EQ(hub.queued_frames(), 1u); + hub.force_send_front(); + ASSERT_EQ(hub.queued_frames(), 0u); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + ASSERT_EQ(hub.queued_frames(), 1u); + const ModbusDeviceCommand &requeued = hub.front(); + EXPECT_EQ(requeued.device, &device); + // address + PDU + CRC + ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); + EXPECT_EQ(requeued.frame.data.data()[0], 0x02); + EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU))); +} + +// A device that declines the retry has the frame dropped. +TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on +// destruction), a timeout must not deliver a callback or re-queue anything. +TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { + NoResponseProbeHub hub; + { + RetryingDevice device(&hub, 0x02, /*retry=*/true); + device.send_pdu(read_pdu()); + hub.force_send_front(); + // device destructor clears its queue entries, including the waiting frame's device pointer + } + ASSERT_TRUE(hub.waiting()); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + hub.timeout_waiting(); + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the +// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the +// send-wait timeout clears it - without a second no-response callback or a duplicate requeue. +TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. + const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload)); + + EXPECT_EQ(device.no_response_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... + EXPECT_EQ(hub.front().device, &device); + ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot + EXPECT_TRUE(hub.waiting_command().interrupted); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + // The send-wait timeout clears the shell without a second callback or another requeue. + hub.timeout_waiting(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 1u); +} + +// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request: +// no orphaned frame with a null device is re-queued. +TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { + NoResponseProbeHub hub; + ClearingRetryDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device + EXPECT_FALSE(hub.waiting()); +} + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index ecdca4df6dc..1c57a81e6f7 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -181,17 +181,17 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { const std::vector data{0x12, 0x34, 0x56}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); } // --- registers_to_number --------------------------------------------------- @@ -218,16 +218,15 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { const uint16_t registers[] = {0x8001, 0x0002}; const std::vector bytes{0x80, 0x01, 0x00, 0x02}; for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { - EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF)) + EXPECT_EQ(registers_to_number(registers, 2, value_type), + payload_to_number(std::span(bytes), value_type, 0, 0xFFFFFFFF)) << "value_type=" << static_cast(value_type); } } TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { const uint16_t registers[] = {0x1234}; - bool error = false; - EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0); - EXPECT_TRUE(error); + EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } } // namespace esphome::modbus::helpers From 65ef05dd1f388c7ff9793e8a074c0c4babecfb4d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:51:00 +1200 Subject: [PATCH 322/343] [web_server_idf] Deliver raw POST bodies to custom handlers via handleBody() (#17433) --- .../web_server_idf/web_server_idf.cpp | 65 ++++++++++++++++--- .../web_server_idf/web_server_idf.h | 1 + 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index cd06f806878..69b27e90ed2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -41,6 +41,11 @@ namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf"; +// Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size. +// Buffers of this size must live on the heap - the httpd task stack is too small. +static constexpr size_t RECV_CHUNK_SIZE = 1460; +static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog + // Global instance to avoid guard variable (saves 8 bytes) // This is initialized at program startup before any threads namespace { @@ -184,9 +189,10 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return server->handle_multipart_upload_(r, content_type_char); #endif } else { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); + // Other content types (e.g. application/json) are delivered raw to a matching + // custom handler via handleBody(), like the Arduino AsyncWebServer does + auto *server = static_cast(r->user_ctx); + return server->handle_raw_body_(r, content_type_char); } } @@ -237,6 +243,51 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const return ESP_ERR_NOT_FOUND; } +esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_type) { + AsyncWebServerRequest req(r); + AsyncWebHandler *handler = nullptr; + for (auto *h : this->handlers_) { + if (h->canHandle(&req)) { + handler = h; + break; + } + } + + if (handler == nullptr) { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type); + // fallback to get handler to support backward compatibility + return this->request_handler_(&req); + } + + const size_t total = r->content_len; + if (total > 0) { + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); + size_t bytes_since_yield = 0; + + for (size_t index = 0; index < total;) { + int recv_len = httpd_req_recv(r, buffer.get(), std::min(total - index, RECV_CHUNK_SIZE)); + + if (recv_len <= 0) { + httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, + nullptr); + return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + + handler->handleBody(&req, reinterpret_cast(buffer.get()), recv_len, index, total); + index += recv_len; + bytes_since_yield += recv_len; + + if (bytes_since_yield > YIELD_INTERVAL_BYTES) { + vTaskDelay(1); + bytes_since_yield = 0; + } + } + } + + handler->handleRequest(&req); + return ESP_OK; +} + AsyncWebServerRequest::~AsyncWebServerRequest() { delete this->rsp_; for (auto *param : this->params_) { @@ -893,9 +944,6 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e #ifdef USE_WEBSERVER_OTA esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { - static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size - static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog - // Parse boundary and create reader const char *boundary_start; size_t boundary_len; @@ -949,12 +997,11 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } }); - // Use heap buffer - 1460 bytes is too large for the httpd task stack - auto buffer = std::make_unique_for_overwrite(MULTIPART_CHUNK_SIZE); + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); size_t bytes_since_yield = 0; for (size_t remaining = r->content_len; remaining > 0;) { - int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); + int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, RECV_CHUNK_SIZE)); if (recv_len <= 0) { httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c631cd14531..8b5fd5b7261 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -233,6 +233,7 @@ class AsyncWebServer { static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd); + esp_err_t handle_raw_body_(httpd_req_t *r, const char *content_type); #ifdef USE_WEBSERVER_OTA esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); #endif From b8af90750fde582cf1f109d0ab14e94b482a76bc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:18:46 +1200 Subject: [PATCH 323/343] [web_server_idf] Map more common HTTP status codes in responses (#17447) --- .../web_server_idf/web_server_idf.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 69b27e90ed2..46a389f359f 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -32,9 +32,16 @@ namespace esphome::web_server_idf { +// Status strings not provided by esp_http_server.h +#ifndef HTTPD_401 +#define HTTPD_401 "401 Unauthorized" +#endif #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" #endif +#ifndef HTTPD_422 +#define HTTPD_422 "422 Unprocessable Entity" +#endif #define CRLF_STR "\r\n" #define CRLF_LEN (sizeof(CRLF_STR) - 1) @@ -327,12 +334,24 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code case 200: status = HTTPD_200; break; + case 204: + status = HTTPD_204; + break; + case 400: + status = HTTPD_400; + break; + case 401: + status = HTTPD_401; + break; case 404: status = HTTPD_404; break; case 409: status = HTTPD_409; break; + case 422: + status = HTTPD_422; + break; default: status = HTTPD_500; break; From 93bc02b3085b8c6e9c7c330164a6d34dd8120828 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:39:55 -0500 Subject: [PATCH 324/343] Bump bundled esphome-device-builder to 1.3.0 (#17448) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c01a2069f7a..3a7d5e8bbe3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.2.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.3.0 RUN \ platformio settings set enable_telemetry No \ From 9c40ed5d711e7720567a6ccfae5f6db31ea3b99d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 8 Jul 2026 01:01:20 -0500 Subject: [PATCH 325/343] [provisioning] Add provisioning window (#17152) Co-authored-by: Claude Opus 4.8 (1M context) --- CODEOWNERS | 1 + esphome/components/api/__init__.py | 18 +++ esphome/components/api/api.proto | 14 +++ esphome/components/api/api_connection.cpp | 28 ++++- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2.cpp | 20 ++++ esphome/components/api/api_pb2.h | 12 +- esphome/components/api/api_pb2_dump.cpp | 13 ++- esphome/components/api/api_pb2_service.cpp | 6 +- esphome/components/api/api_pb2_service.h | 2 +- esphome/components/api/api_server.cpp | 61 ++++++++-- esphome/components/api/api_server.h | 21 +++- .../esp32_improv/esp32_improv_component.cpp | 31 ++++++ esphome/components/network/__init__.py | 75 ++++++++----- esphome/components/provisioning/__init__.py | 104 ++++++++++++++++++ .../components/provisioning/provisioning.cpp | 92 ++++++++++++++++ .../components/provisioning/provisioning.h | 96 ++++++++++++++++ esphome/components/wifi/__init__.py | 16 +++ esphome/components/wifi/wifi_component.cpp | 20 +++- esphome/core/defines.h | 1 + .../provisioning/test_provisioning.py | 84 ++++++++++++++ .../provisioning/test.esp32-idf.yaml | 25 +++++ .../provisioning/test.esp8266-ard.yaml | 16 +++ .../provisioning/validate.esp32-idf.yaml | 15 +++ 24 files changed, 724 insertions(+), 49 deletions(-) create mode 100644 esphome/components/provisioning/__init__.py create mode 100644 esphome/components/provisioning/provisioning.cpp create mode 100644 esphome/components/provisioning/provisioning.h create mode 100644 tests/component_tests/provisioning/test_provisioning.py create mode 100644 tests/components/provisioning/test.esp32-idf.yaml create mode 100644 tests/components/provisioning/test.esp8266-ard.yaml create mode 100644 tests/components/provisioning/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 34ec4bc2bdf..821d2e5e745 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -404,6 +404,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core +esphome/components/provisioning/* @esphome/core esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 11ada7e970f..64b025fee1a 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register the API as a provisioning source when encryption is enabled. + + With no ``key`` the device boots unprovisioned and is set up on first + connection; a YAML ``key`` means it is born provisioned. Either way the API + drives the provisioning manager, so it counts as a source for `provisioning:`. + A hardcoded ``key`` is reported so `provisioning:` can warn about it. + """ + if (encryption := config.get(CONF_ENCRYPTION)) is not None: + from esphome.components import provisioning + + provisioning.register_source("api") + if CONF_KEY in encryption: + provisioning.report_hardcoded_credentials("api") + return config + + def validate_encryption_key(value): value = cv.string_strict(value) try: @@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), _consume_api_sockets, + _register_provisioning_source, ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f4f15c10428..86707d98105 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -158,6 +158,16 @@ message AuthenticationResponse { bool invalid_password = 1; } +// Reason a party is requesting the connection be closed. +enum DisconnectReason { + // No specific reason / not provided (default for older peers). + DISCONNECT_REASON_UNSPECIFIED = 0; + // The device's provisioning window has expired. The device must be reset + // (power-cycled) to reopen the provisioning window before it will accept a + // connection again. + DISCONNECT_REASON_PROVISIONING_CLOSED = 1; +} + // Request to close the connection. // Can be sent by both the client and server message DisconnectRequest { @@ -166,6 +176,10 @@ message DisconnectRequest { option (no_delay) = true; // Do not close the connection before the acknowledgement arrives + + // Optional reason the connection is being closed. Older peers that do not + // send this field will report DISCONNECT_REASON_UNSPECIFIED (0). + DisconnectReason reason = 1; } message DisconnectResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb7d1b9d1e0..dcb1478ec87 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -25,6 +25,9 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -1724,6 +1727,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + // The provisioning window has closed without the device being provisioned. + // Acknowledge the hello so the client can read the server name, then request + // disconnect with the reason. Authentication is intentionally not completed. + this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); + this->send_message(resp); + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + return this->send_message(req); + } +#endif + // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); @@ -1874,7 +1890,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } -void APIConnection::on_disconnect_request() { +void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) { + // The reason is informational when a client disconnects us; we always ack and close. if (!this->send_disconnect_response_()) { this->on_fatal_error(); } @@ -2002,6 +2019,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio NoiseEncryptionSetKeyResponse resp; resp.success = false; +#ifdef USE_PROVISIONING + // Refuse to set a key once the provisioning window has closed (defense in depth; + // such connections are already rejected at hello). + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning closed; rejecting key set"); + return this->send_message(resp); + } +#endif + psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index dae5fc92fd0..d6d3e4d26b7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -259,7 +259,7 @@ class APIConnection final : public APIServerConnectionBase { void on_get_time_response(const GetTimeResponse &value); #endif void on_hello_request(const HelloRequest &msg); - void on_disconnect_request(); + void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c711ef167c8..de6ae4751e3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const { size += 2 + this->name.size(); return size; } +bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->reason = static_cast(value); + break; + default: + return false; + } + return true; +} +uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + return pos; +} +uint32_t DisconnectRequest::calculate_size() const { + uint32_t size = 0; + size += this->reason ? 2 : 0; + return size; +} #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e926ee0d47..d268a40c567 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -11,6 +11,10 @@ namespace esphome::api { namespace enums { +enum DisconnectReason : uint32_t { + DISCONNECT_REASON_UNSPECIFIED = 0, + DISCONNECT_REASON_PROVISIONING_CLOSED = 1, +}; enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, @@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage { protected: }; -class DisconnectRequest final : public ProtoMessage { +class DisconnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; - static constexpr uint8_t ESTIMATED_SIZE = 0; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif + enums::DisconnectReason reason{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class DisconnectResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 850ad37bc96..3a1ceba95fe 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint } #pragma GCC diagnostic pop +template<> const char *proto_enum_to_string(enums::DisconnectReason value) { + switch (value) { + case enums::DISCONNECT_REASON_UNSPECIFIED: + return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED"); + case enums::DISCONNECT_REASON_PROVISIONING_CLOSED: + return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) { switch (value) { case enums::SERIAL_PROXY_PORT_TYPE_TTL: @@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } const char *DisconnectRequest::dump_to(DumpBuffer &out) const { - out.append_p(ESPHOME_PSTR("DisconnectRequest {}")); + MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest")); + dump_field(out, ESPHOME_PSTR("reason"), static_cast(this->reason)); return out.c_str(); } const char *DisconnectResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 0ba2961a138..5c9df433dd0 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } case DisconnectRequest::MESSAGE_TYPE: { + DisconnectRequest msg; + msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_request")); + this->log_receive_message_(LOG_STR("on_disconnect_request"), msg); #endif - this->on_disconnect_request(); + this->on_disconnect_request(msg); break; } case DisconnectResponse::MESSAGE_TYPE: { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index aca42ca303d..d1b51f4846d 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -21,7 +21,7 @@ class APIServerConnectionBase { void on_hello_request(const HelloRequest &value){}; - void on_disconnect_request(){}; + void on_disconnect_request(const DisconnectRequest &value){}; void on_disconnect_response(){}; void on_ping_request(){}; void on_ping_response(){}; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index efdeb6991b1..1062dfeb395 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -107,8 +107,30 @@ void APIServer::setup() { // Initialize last_connected_ for reboot timeout tracking this->last_connected_ = App.get_loop_component_start_time(); - // Set warning status if reboot timeout is enabled - if (this->reboot_timeout_ != 0) { +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Register with the provisioning manager (provisioning:) as a source and + // report our current state (provisioned == an encryption key is set). When the + // window closes, disconnect any client still attempting to provision so it learns + // the reason. The manager owns the timeout, window state and on_timeout automation. + if (provisioning::global_provisioning_manager != nullptr) { + this->provisioning_source_ = provisioning::global_provisioning_manager->register_source(); + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, + this->noise_ctx_.has_psk()); + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + for (auto &c : this->active_clients()) { + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + // Best-effort: if the send buffer is full the reason is dropped, but the + // client still learns the window is closed when it reconnects (rejected at + // hello) or via the socket close. + c->send_message(req); + } + }); + } +#endif + // Set warning status if reboot timeout is enabled (suppressed while provisioning + // is pending so the device waits to be onboarded instead of rebooting). + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); } } @@ -121,8 +143,10 @@ void APIServer::loop() { if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn - // (cancelled scheduler items sit in heap memory until their scheduled time) - if (this->reboot_timeout_ != 0) { + // (cancelled scheduler items sit in heap memory until their scheduled time). + // Suppressed while a provisioning window is pending so the device waits to be + // onboarded / reset instead of rebooting itself; resumes once provisioned. + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_connected_ > this->reboot_timeout_) { ESP_LOGE(TAG, "No clients; rebooting"); @@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) { this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout - if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { + // (suppressed while provisioning is pending - see loop()). + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { conn->start(); // First client connected - clear warning and update timestamp - if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -572,8 +597,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { } SavedNoisePsk new_saved_psk{psk}; - return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), - make_active); + bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The device now has a key; report provisioned so the provisioning window is + // satisfied and the reboot timeout resumes normal operation. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true); + } +#endif + return result; #endif } bool APIServer::clear_noise_psk(bool make_active) { @@ -584,8 +617,16 @@ bool APIServer::clear_noise_psk(bool make_active) { return false; #else SavedNoisePsk empty_psk{}; - return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), - make_active); + bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The key was cleared; report unprovisioned so a subsequent reboot reopens the + // provisioning window. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false); + } +#endif + return result; #endif } #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 16b5762f683..248b83a0ffa 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,6 +14,9 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -255,6 +258,19 @@ class APIServer final : public Component, // Remove a disconnected client by index. Swaps with the last populated slot and resets it. void __attribute__((noinline)) remove_client_(uint8_t client_index); +#ifdef USE_PROVISIONING + // True while a configured provisioning window is still pending (the device is + // unprovisioned). Suppresses the reboot timeout and its warning so the device is + // not auto-rebooted while waiting to be provisioned. False when no provisioning + // window is configured. + bool provisioning_pending_() const { + return provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); + } +#else + bool provisioning_pending_() const { return false; } +#endif + #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active); @@ -332,7 +348,10 @@ class APIServer final : public Component, uint8_t listen_backlog_{4}; bool shutting_down_ = false; uint8_t api_connection_count_{0}; - // 7 bytes used, 1 byte padding +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Index assigned by the provisioning manager for reporting this transport's state. + uint8_t provisioning_source_{0}; +#endif #ifdef USE_API_NOISE APINoiseContext noise_ctx_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index e6fcc018d91..6e3a4ef526a 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -7,6 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + #ifdef USE_ESP32 namespace esphome::esp32_improv { @@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + ESP_LOGD(TAG, "Provisioning window closed; stopping Improv"); + this->stop(); + }); + } +#endif + // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); } @@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; +#ifdef USE_PROVISIONING + // Don't (re)start advertising once the provisioning window has closed - e.g. when + // wifi tries to restart Improv after the window expired at runtime. + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGD(TAG, "Provisioning window closed; not starting Improv"); + return; + } +#endif + ESP_LOGD(TAG, "Setting Improv to start"); this->should_start_ = true; this->enable_loop(); @@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning window closed; refusing settings"); + this->set_error_(improv::ERROR_NOT_AUTHORIZED); + this->incoming_data_.clear(); + return; + } +#endif if (wifi::global_wifi_component->is_disabled()) { // Wi-Fi is disabled, so we can't provision. Respond immediately // instead of letting the client wait out its provisioning timeout. diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b7dfb8d6d2b..0f4bcb3e169 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -25,6 +25,20 @@ NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register network connectivity as a provisioning source. + + The network component is auto-loaded whenever an interface (wifi, ethernet, ...) + is configured, so a device with connectivity always has this source: it is + considered provisioned once it has connected via any interface, and + `provisioning:` is valid without another source. + """ + from esphome.components import provisioning + + provisioning.register_source("network") + return config + + def ip_address_literal(ip: str | int | None) -> cg.MockObj: """Generate an IPAddress with compile-time initialization instead of runtime parsing. @@ -128,36 +142,41 @@ def validate_ipv6(value: bool) -> bool: return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(NetworkComponent), - cv.SplitDefault( - CONF_ENABLE_IPV6, - bk72xx=False, - esp32=False, - esp8266=False, - host=False, - rp2=False, - nrf52=True, - ): cv.All( - cv.boolean, - cv.Any( - cv.require_framework_version( - bk72xx_arduino=cv.Version(1, 7, 0), - esp_idf=cv.Version(0, 0, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp8266_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - rp2_arduino=cv.Version(0, 0, 0), - nrf52_zephyr=cv.Version(0, 0, 0), +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(NetworkComponent), + cv.SplitDefault( + CONF_ENABLE_IPV6, + bk72xx=False, + esp32=False, + esp8266=False, + host=False, + rp2=False, + nrf52=True, + ): cv.All( + cv.boolean, + cv.Any( + cv.require_framework_version( + bk72xx_arduino=cv.Version(1, 7, 0), + esp_idf=cv.Version(0, 0, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp8266_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), + ), + cv.boolean_false, ), - cv.boolean_false, + validate_ipv6, ), - validate_ipv6, - ), - cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, - cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), - } + cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, + cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All( + cv.boolean, cv.only_on_esp32 + ), + } + ), + _register_provisioning_source, ) diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py new file mode 100644 index 00000000000..36fa69357a9 --- /dev/null +++ b/esphome/components/provisioning/__init__.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass, field +import logging + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_TIMEOUT, CONF_TIMEOUT +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] +DOMAIN = "provisioning" + +_LOGGER = logging.getLogger(__name__) + +provisioning_ns = cg.esphome_ns.namespace("provisioning") +ProvisioningManager = provisioning_ns.class_("ProvisioningManager", cg.Component) + + +@dataclass +class ProvisioningData: + # Names of the components that registered as a provisioning source this run. + sources: set[str] = field(default_factory=set) + # Names of source components that have their credentials set in the config. + hardcoded_credentials: set[str] = field(default_factory=set) + + +def _get_data() -> ProvisioningData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ProvisioningData() + return CORE.data[DOMAIN] + + +def register_source(name: str) -> None: + """Record that ``name`` is a provisioning source for this configuration. + + A provisioning-capable component (a transport that boots unprovisioned and is + set up by the controller on first connection, or a network interface that + provisions once connected) calls this while its own config is being processed, + typically from a schema validator. `provisioning:` then confirms at least one + source is present without inspecting the full config or knowing about any + specific component. State lives in CORE.data, which is cleared between runs. + """ + _get_data().sources.add(name) + + +def report_hardcoded_credentials(name: str) -> None: + """Record that source component ``name`` has its credentials set in the config. + + A source component calls this from its own validator when it finds baked-in + credentials (a WiFi SSID/password, an API encryption key, ...). `provisioning:` + warns about these, since a device that ships with credentials does not need a + provisioning window. The warning is emitted here, by `provisioning:`, so the + source components stay unaware of it. + """ + _get_data().hardcoded_credentials.add(name) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ProvisioningManager), + cv.Required(CONF_TIMEOUT): cv.All( + cv.positive_not_null_time_period, cv.positive_time_period_milliseconds + ), + cv.Optional(CONF_ON_TIMEOUT): automation.validate_automation(single=True), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate the provisioning setup once every component has been processed. + + Sources register during their own config validation, so by final validation + both the source set and the hardcoded-credentials set are complete. + """ + data = _get_data() + if not data.sources: + raise cv.Invalid( + "'provisioning' requires at least one provisioning-capable component: " + "configure a network interface such as 'wifi:' or 'ethernet:', or enable " + "'api:' with 'encryption:' and no 'key:' so the device boots " + "unprovisioned and is configured on first connection." + ) + if data.hardcoded_credentials: + _LOGGER.warning( + "'provisioning' is configured, but credentials are set in the " + "configuration for: %s. A device that uses a provisioning window should " + "ship without credentials so they are set on first connection; " + "hardcoding them makes the window pointless.", + ", ".join(sorted(data.hardcoded_credentials)), + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_PROVISIONING") + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + if on_timeout := config.get(CONF_ON_TIMEOUT): + await automation.build_automation(var.get_timeout_trigger(), [], on_timeout) diff --git a/esphome/components/provisioning/provisioning.cpp b/esphome/components/provisioning/provisioning.cpp new file mode 100644 index 00000000000..02c089bfed3 --- /dev/null +++ b/esphome/components/provisioning/provisioning.cpp @@ -0,0 +1,92 @@ +#include "esphome/components/provisioning/provisioning.h" +#ifdef USE_PROVISIONING +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + +#include + +namespace esphome::provisioning { + +static const char *const TAG = "provisioning"; + +ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + nullptr; + +ProvisioningManager::ProvisioningManager() { + global_provisioning_manager = this; +#ifdef USE_NETWORK + // Network connectivity is a built-in provisioning source. Registered here rather + // than from a source's setup() because connectivity is universal, not a pluggable + // transport; loop() latches it provisioned once the device has connected. + this->network_source_ = this->register_source(); +#endif +} + +uint8_t ProvisioningManager::register_source() { + if (this->source_count_ >= MAX_SOURCES) { + // Defensive: only a handful of sources exist in practice. Fail loudly rather + // than shifting past the mask width (undefined behavior). The returned index is + // ignored by set_source_provisioned()'s bounds check. + ESP_LOGE(TAG, "Too many provisioning sources (max %u)", MAX_SOURCES); + return this->source_count_; + } + uint8_t source = this->source_count_++; + this->registered_mask_ |= (1UL << source); + return source; +} + +void ProvisioningManager::loop() { + // Sources register during their own setup() (at various priorities), and this + // loop() also runs while waiting on a slow component during setup. Evaluating the + // provisioning state before every source has registered could conclude + // "provisioned" prematurely and disable_loop() for good, defeating the window -- + // so do nothing until all setup() calls are done. + if (!App.is_setup_complete()) + return; + +#ifdef USE_NETWORK + // Latch the built-in connectivity source once the device has been reachable via + // any interface. network::is_connected() aggregates wifi/ethernet/modem/... (OR + // across interfaces), and a disabled interface never connects so it never + // contributes. Latched: a later link drop does not un-provision -- the RAM-only + // window still reopens only on reboot. + if ((this->provisioned_mask_ & (1UL << this->network_source_)) == 0 && network::is_connected()) + this->set_source_provisioned(this->network_source_, true); +#endif + + // The window is resolved once the device is provisioned or the window has closed; + // there is nothing left to track, so stop running entirely. Config validation + // guarantees at least one source, so is_provisioned() is never vacuously true here. + if (this->closed_ || this->is_provisioned()) { + this->disable_loop(); + return; + } + // The window timer runs from boot (millis since boot). The closed state is not + // persisted, so a reboot reopens the window. + if (this->timeout_ != 0 && App.get_loop_component_start_time() > this->timeout_) { + this->close_window_(); + } +} + +void ProvisioningManager::close_window_() { + this->closed_ = true; + ESP_LOGW(TAG, "Window expired; cycle power to reopen window"); + // Notify internal consumers first (transports disconnect clients, Improv stops), + // then fire the user-facing automation. + this->closed_callback_.call(); + this->timeout_trigger_.trigger(); +} + +void ProvisioningManager::dump_config() { + ESP_LOGCONFIG(TAG, + "Provisioning:\n" + " Timeout: %" PRIu32 "ms\n" + " Provisioned: %s", + this->timeout_, YESNO(this->is_provisioned())); +} + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/provisioning/provisioning.h b/esphome/components/provisioning/provisioning.h new file mode 100644 index 00000000000..e21b8f3ef09 --- /dev/null +++ b/esphome/components/provisioning/provisioning.h @@ -0,0 +1,96 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_PROVISIONING +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::provisioning { + +// Central provisioning-window manager (EN18031). A device that ships unprovisioned +// (secure transports enabled with no credentials, configured by the controller on +// first connection) opens a provisioning window at boot. Each transport that needs +// provisioning registers as a "source" and reports its state; the device is +// considered provisioned once every registered source is provisioned. +// +// Network connectivity is a built-in source: a device with a network interface but +// no other provisioning-capable component (no api encryption, etc.) is still +// considered provisioned once it has connected via any interface -- so an +// Improv-only device reports its state correctly. +// +// If the window times out while still unprovisioned it closes: the closed state is +// RAM-only (a power cycle / reset reopens it) and the `on_timeout` automation fires. +// Components query window_pending()/closed() to suppress reboot timeouts and refuse +// further provisioning. This manager owns no transport knowledge; transports +// (api, and later mqtt/wireguard/...) drive it through the source API. +class ProvisioningManager : public Component { + public: + // Maximum number of provisioning sources, limited by the width of the state masks. + static constexpr uint8_t MAX_SOURCES = 32; + + ProvisioningManager(); + + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BEFORE_CONNECTION; } + + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } + + // Register a provisioning source. Returns a bit index the source uses to report + // its state via set_source_provisioned(). Call once, from the source's setup(). + uint8_t register_source(); + // Report whether the given source currently holds valid credentials. + void set_source_provisioned(uint8_t source, bool provisioned) { + if (source >= MAX_SOURCES) + return; + if (provisioned) { + this->provisioned_mask_ |= (1UL << source); + } else { + this->provisioned_mask_ &= ~(1UL << source); + } + } + + // True once every registered source is provisioned. Config validation guarantees + // at least one source, and the built-in connectivity source registers in the + // constructor, so registered_mask_ is never zero in practice. + bool is_provisioned() const { return (this->provisioned_mask_ & this->registered_mask_) == this->registered_mask_; } + // True while provisioning is still pending: the device is unprovisioned, whether + // the window is still open or has already closed. Reboot timeouts are suppressed + // while this holds so the device never auto-reboots (and silently reopens the + // window) while unprovisioned. + bool window_pending() const { return !this->is_provisioned(); } + // True once the window has expired without the device being provisioned. + bool closed() const { return this->closed_; } + + // Register a callback fired once when the window closes (runtime expiry). Used + // internally by transports/Improv to stop accepting provisioning. The user-facing + // on_timeout automation is wired to get_timeout_trigger() instead. + template void add_on_closed_callback(F &&callback) { + this->closed_callback_.add(std::forward(callback)); + } + Trigger<> *get_timeout_trigger() { return &this->timeout_trigger_; } + + protected: + void close_window_(); + + Trigger<> timeout_trigger_; + LazyCallbackManager closed_callback_; + uint32_t timeout_{0}; + uint32_t registered_mask_{0}; + uint32_t provisioned_mask_{0}; + uint8_t source_count_{0}; + bool closed_{false}; +#ifdef USE_NETWORK + // Built-in connectivity source (see loop()): registered in the constructor and + // latched provisioned once the device has connected via any network interface. + uint8_t network_source_{0}; +#endif +}; + +extern ProvisioningManager *global_provisioning_manager; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index dc5c8be4d7d..137304c8075 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -436,6 +436,21 @@ def _validate(config): return config +def _report_provisioning_credentials(config): + """Report baked-in STA credentials to the provisioning component (if used). + + `_validate` has already folded any ``ssid``/``password`` into ``networks``, so a + non-empty list means credentials are set in the config. `provisioning:` warns + about this, since a device that uses a provisioning window should get its + credentials on first connection instead. + """ + if config.get(CONF_NETWORKS): + from esphome.components import provisioning + + provisioning.report_hardcoded_credentials("wifi") + return config + + CONF_PASSIVE_SCAN = "passive_scan" FAST_CONNECT_SCHEMA = cv.Schema( @@ -517,6 +532,7 @@ CONFIG_SCHEMA = cv.All( ), _apply_min_auth_mode_default, _validate, + _report_provisioning_credentials, ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index c951e74358f..44e3cb6af91 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -45,6 +45,10 @@ #include "esphome/components/improv_serial/improv_serial_component.h" #endif +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + namespace esphome::wifi { static const char *const TAG = "wifi"; @@ -872,8 +876,20 @@ void WiFiComponent::loop() { if (!this->has_ap() && this->reboot_timeout_ != 0) { if (now - this->last_connected_ > this->reboot_timeout_) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); + bool suppress = false; +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress = provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); + } } } } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1d09bb5c5cd..639508a7b2e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -153,6 +153,7 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP +#define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py new file mode 100644 index 00000000000..07f50652419 --- /dev/null +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -0,0 +1,84 @@ +"""Tests for the provisioning component config validation.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome import config_validation as cv +from esphome.components.provisioning import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + register_source, + report_hardcoded_credentials, +) +from esphome.const import CONF_TIMEOUT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_provisioning_requires_a_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """Provisioning with no registered source is a config error. + + Sources register themselves during their own config validation; with none + registered the window could never resolve, so validation fails. + """ + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="provisioning-capable component"): + FINAL_VALIDATE_SCHEMA({}) + + +def test_provisioning_accepts_a_registered_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that registered as a provisioning source satisfies validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + # Should not raise. + assert FINAL_VALIDATE_SCHEMA({}) == {} + + +def test_provisioning_warns_on_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A source with credentials set in the config triggers a warning.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + report_hardcoded_credentials("wifi") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "wifi" in caplog.text + assert "credentials" in caplog.text + + +def test_provisioning_no_warning_without_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No credentials warning when no source reports hardcoded credentials.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "credentials" not in caplog.text + + +def test_provisioning_rejects_zero_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A zero timeout would leave the window open forever, so it is rejected.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_TIMEOUT: "0s"}) + + +def test_provisioning_accepts_positive_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A positive timeout is accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_TIMEOUT: "5min"}) + assert config[CONF_TIMEOUT].total_milliseconds == 300000 diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml new file mode 100644 index 00000000000..24168881fc9 --- /dev/null +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -0,0 +1,25 @@ +# Exercises the provisioning window: api registers as a provisioning source +# (encryption enabled, no key), the on_timeout automation, and the wifi + +# esp32_improv cross-component guards. improv_serial is intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: + +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button + +esp32_improv: + authorizer: io0_button diff --git a/tests/components/provisioning/test.esp8266-ard.yaml b/tests/components/provisioning/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4188c00befd --- /dev/null +++ b/tests/components/provisioning/test.esp8266-ard.yaml @@ -0,0 +1,16 @@ +# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source +# and the wifi reboot guard. improv_serial is present and intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: diff --git a/tests/components/provisioning/validate.esp32-idf.yaml b/tests/components/provisioning/validate.esp32-idf.yaml new file mode 100644 index 00000000000..1fd3d67882a --- /dev/null +++ b/tests/components/provisioning/validate.esp32-idf.yaml @@ -0,0 +1,15 @@ +# A device provisioned over the network (wifi / Improv) with no api: network +# connectivity alone satisfies provisioning, so `provisioning:` is valid without an +# api encryption source. Config-only -- exercises the network provisioning-source +# validation path (the Improv-only case from the review). +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +wifi: + ssid: MySSID + password: password1 + +improv_serial: From 2f5465c0e85effce2792df0a8f8f3e1317591d4c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 8 Jul 2026 12:07:42 -0400 Subject: [PATCH 326/343] [sendspin] Suppress WiFi roam scanning while playing (#17133) --- esphome/components/sendspin/__init__.py | 1 + esphome/components/sendspin/sendspin_hub.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e8c643f9b9b..97e7f4e22c5 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -138,6 +138,7 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() + wifi.enable_runtime_roaming_suppression() return config diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 57709306cd0..b95d95b2bcb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -129,6 +129,7 @@ void SendspinHub::on_request_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->request_high_performance(); + wifi::global_wifi_component->request_roaming_suppression(); } #endif } @@ -137,6 +138,7 @@ void SendspinHub::on_release_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->release_high_performance(); + wifi::global_wifi_component->release_roaming_suppression(); } #endif } From bba3a9657bae2ac8edf3e1cc63c0b58154f9ac28 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:17:57 +1000 Subject: [PATCH 327/343] [lvgl] Add animations (#16796) Co-authored-by: clydeps Co-authored-by: Claude Opus 4.8 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 7 +- esphome/components/lvgl/animation.h | 197 +++++++++++++ esphome/components/lvgl/animation.py | 295 +++++++++++++++++++ esphome/components/lvgl/defines.py | 23 +- esphome/components/lvgl/lv_validation.py | 62 ++-- esphome/components/lvgl/types.py | 1 + esphome/core/defines.h | 1 + tests/component_tests/lvgl/test_animation.py | 201 +++++++++++++ tests/components/lvgl/lvgl-package.yaml | 57 ++++ tests/components/lvgl/test.host.yaml | 39 ++- 10 files changed, 854 insertions(+), 29 deletions(-) create mode 100644 esphome/components/lvgl/animation.h create mode 100644 esphome/components/lvgl/animation.py create mode 100644 tests/component_tests/lvgl/test_animation.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ecc4b0a7779..b758390f0d0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,9 +52,11 @@ from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, lv_validation as lvalid, widgets +from .animation import ANIMATION_SCHEMA, add_animation_triggers, animations_to_code from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, + CONF_ANIMATIONS, LOGGER, add_lv_use, get_focused_widgets, @@ -435,7 +437,8 @@ async def to_code(configs): await layers_to_code(lv_component, config) await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) - # await disp_update(lv_component.get_disp(), config) + await animations_to_code(config.get(CONF_ANIMATIONS, [])) + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. set_widgets_completed(True) async with LvContext(): @@ -443,6 +446,7 @@ async def to_code(configs): await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) + await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) await generate_page_triggers(config) await initial_focus_to_code(config) for conf in config.get(CONF_ON_IDLE, ()): @@ -636,6 +640,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( for x in SIMPLE_TRIGGERS }, cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_ANIMATIONS): cv.ensure_list(ANIMATION_SCHEMA), cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h new file mode 100644 index 00000000000..1e0abce3583 --- /dev/null +++ b/esphome/components/lvgl/animation.h @@ -0,0 +1,197 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_LVGL_ANIMATION +#include "lvgl_esphome.h" +#include "esphome/core/hal.h" + +namespace esphome::lvgl { + +enum class AnimationState { + STOPPED, + STARTED, + RUNNING, +}; + +class LvAnimationTiming { + public: + // Map progress in the range [0, 1] + virtual float map_progress(float value) = 0; +}; + +class LvAnimationTimingRoundTrip : public LvAnimationTiming { + public: + float map_progress(float value) override { + value *= 2.0f; + if (value > 1.0f) + return 2.0f - value; + return value; + } +}; + +class LvAnimationTimingGravity : public LvAnimationTiming { + public: + LvAnimationTimingGravity(float acceleration, float bounce) : acceleration_(acceleration), bounce_(bounce) {} + float map_progress(float value) override { + if (value == 0.0f) { + this->initial_position_ = 0.0f; + this->initial_speed_ = 0.0f; + this->initial_time_ = 0.0f; + } + auto position = this->calc_pos_(value); + if (position > 1.0f) { + auto initial_time = this->calc_end_time_(); + this->initial_speed_ = -this->calc_speed_(initial_time) * this->bounce_; + this->initial_position_ = 1.0f; + this->initial_time_ = initial_time; + position = calc_pos_(value); + if (position > 1.0f) { + position = 1.0f; + } + } + return position; + } + + protected: + float calc_pos_(float value) const { + value -= this->initial_time_; + return (0.5 * value * this->acceleration_ + this->initial_speed_) * value + this->initial_position_; + } + + float calc_speed_(float value) const { + value -= this->initial_time_; + return this->acceleration_ * value + this->initial_speed_; + } + + float calc_end_time_() const { + return (-this->initial_speed_ + std::sqrt(this->initial_speed_ * this->initial_speed_ - + 4.0f * this->acceleration_ / 2.0 * (this->initial_position_ - 1.0f))) / + this->acceleration_ + + this->initial_time_; + } + + float acceleration_; + float bounce_; + float initial_position_{0.0f}; + float initial_time_{0.0f}; + float initial_speed_{0.0f}; +}; + +class LvAnimationTimingEaseInOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseInOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + float sqr = value * value; + sqr = sqr / (2.0f * (sqr - value) + 1.0f); + return this->slope_ * sqr + (1.0 - this->slope_) * value; + } + + protected: + float slope_; +}; + +template class LvAnimation : public Component { + public: + LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, + std::vector> to) + : update_callback_(update_callback) { + std::copy(from.begin(), from.end(), this->from_); + std::copy(to.begin(), to.end(), this->to_); + } + + void start() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + this->loop(); + this->start_callback_.call(); + } + + void stop() { + // Only fire the stop callback on a genuine running -> stopped transition, so that + // repeated stop() calls (e.g. start() pre-clearing a stopped animation) don't re-fire it. + if (this->state_ == AnimationState::STOPPED) + return; + this->state_ = AnimationState::STOPPED; + this->stop_callback_.call(); + } + + void setup() override { + if constexpr (AUTO_START) + this->start(); + } + + void loop() override { + if (this->state_ == AnimationState::STOPPED) + return; + uint32_t elapsed = millis() - this->start_time_; + float progress = static_cast(elapsed) / static_cast(this->duration_); + switch (this->state_) { + case AnimationState::STARTED: + if (elapsed < this->start_delay_) + return; + this->state_ = AnimationState::RUNNING; + this->start_time_ = millis(); + progress = 0.0f; + break; + case AnimationState::RUNNING: + if (progress >= 1.0f) { + progress = 1.0f; + this->stop(); + if (this->loop_) + this->start(); + } + break; + default: + return; + } + + for (auto *timing : this->timings_) { + progress = timing->map_progress(progress); + } + lv_coord_t data[DATA_SIZE]; + for (size_t i = 0; i != DATA_SIZE; i++) { + data[i] = static_cast( + roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); + } + this->update_callback_(data); + } + + float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; } + void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); } + void set_loop(bool loop) { this->loop_ = loop; } + + template void add_on_start_callback(F &&callback) { + this->start_callback_.add(std::forward(callback)); + } + template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } + + protected: + void (*const update_callback_)(const lv_coord_t *data); + LazyCallbackManager start_callback_{}; + LazyCallbackManager stop_callback_{}; + TemplatableValue from_[DATA_SIZE]{}; + TemplatableValue to_[DATA_SIZE]{}; + uint32_t duration_{0}; + uint32_t start_delay_{0}; + uint32_t start_time_{0}; + lv_coord_t data_from_[DATA_SIZE]{0}; + lv_coord_t data_to_[DATA_SIZE]{0}; + AnimationState state_{AnimationState::STOPPED}; + std::vector timings_{}; + bool loop_{false}; +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_ANIMATION diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py new file mode 100644 index 00000000000..2b1500f2c47 --- /dev/null +++ b/esphome/components/lvgl/animation.py @@ -0,0 +1,295 @@ +from esphome import automation, codegen as cg, config_validation as cv +from esphome.automation import Trigger, build_automation +from esphome.config_validation import COMPONENT_SCHEMA +from esphome.const import ( + CONF_ACCELERATION, + CONF_DURATION, + CONF_FROM, + CONF_ID, + CONF_ON_START, + CONF_TIMING, + CONF_TO, + CONF_TRIGGER_ID, + CONF_TYPE, + CONF_WEIGHT, +) +from esphome.cpp_generator import MockObj, TemplateArguments + +from ..const import CONF_LOOP +from .defines import ( + CONF_AUTO_START, + CONF_LVGL_ID, + CONF_ON_STOP, + CONF_WIDGETS, + LValidator, + add_define, + literal, +) +from .lv_validation import ( + color, + get_component_colors, + lv_color, + lv_milliseconds, + lv_positive_float, + lv_zero_to_one_float, +) +from .lvcode import LVGL_COMP_ARG, LambdaContext, LvglComponent, lv_add +from .schemas import STYLE_PROPS +from .types import LvAnimation, LvglAction, lv_color_t, lv_coord_t, lv_obj_t, lvgl_ns +from .widgets import get_widgets + +LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") +LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") + +CONF_BOUNCE = "bounce" + + +def timing_class(name, extras=None): + # Convert config option to camel case + cls_name = "LvAnimationTiming" + "".join([w.capitalize() for w in name.split("_")]) + cls = lvgl_ns.class_(cls_name) + schema = cv.Schema({cv.GenerateID(): cv.declare_id(cls)}) + if extras: + schema = schema.extend(extras) + return name, schema + + +# TODO - currently the order of arguments to timing classes is expected to be alphabetical, but this is not enforced. +# It would be better to have a more robust way of passing arguments to the timing classes. +TIMING_SCHEMA = cv.maybe_simple_value( + cv.typed_schema( + dict( + [ + timing_class("round_trip"), + timing_class( + "ease_in_out", + {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + ), + timing_class( + "gravity", + { + cv.Optional(CONF_ACCELERATION, default=0.5): lv_positive_float, + cv.Optional(CONF_BOUNCE, default=0.5): lv_zero_to_one_float, + }, + ), + ] + ), + default_type="ease_in_out", + ), + key=CONF_TYPE, +) + +CONF_START_DELAY = "start_delay" + + +class LiteralColorValidator(LValidator): + def __init__(self): + super().__init__( + color, lv_color_t, retmapper=get_component_colors, animatable=True + ) + + def __call__(self, value): + if isinstance(value, cv.Lambda): + raise cv.Invalid( + "An animated color may not be set with a lambda, only a literal color value." + ) + return super().__call__(value) + + +literal_color = LiteralColorValidator() + + +def from_to(validator): + return cv.Schema( + { + cv.Required(CONF_FROM): validator, + cv.Required(CONF_TO): validator, + } + ) + + +# Colors can only be animated between constants, not lambdas. +def map_v(validator): + if validator == lv_color: + return literal_color + return validator + + +ANIMABLE_STYLES = { + k: map_v(v) + for k, v in STYLE_PROPS.items() + if isinstance(v, LValidator) and v.animatable +} + +ANIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_AUTO_START, default=False): cv.boolean, + cv.Optional(CONF_LOOP, default=False): cv.boolean, + cv.Optional(CONF_DURATION, default="5s"): lv_milliseconds, + cv.Optional(CONF_START_DELAY, default="0s"): lv_milliseconds, + cv.Optional(CONF_TIMING, default=[]): cv.ensure_list(TIMING_SCHEMA), + cv.Required(CONF_ID): cv.declare_id(LvAnimation), + cv.Optional(CONF_ON_START): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Required(CONF_WIDGETS): cv.ensure_list( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_obj_t), + } + ).extend({cv.Optional(k): from_to(v) for k, v in ANIMABLE_STYLES.items()}) + ), + } +).extend(COMPONENT_SCHEMA) + + +async def _process_arg(validator, arg) -> list: + # from/to values are evaluated at animation start with no arguments, so the + # generated lambda must be parameterless rather than inheriting the enclosing + # update-callback's `values` parameter. + value = await validator.process(arg, args=[], raw_lambda=True) + value = list(value) if isinstance(value, tuple) else [value] + return [literal(f"TemplatableValue({v})") for v in value] + + +async def animations_to_code(config): + for animation in config: + add_define("USE_LVGL_ANIMATION") + widgets = animation[CONF_WIDGETS] + async with LambdaContext( + [(lv_coord_t.operator("const").operator("ptr"), "values")] + ) as ctx: + froms = [] + tos = [] + for widget in widgets: + w = (await get_widgets(widget))[0] + props = [(k, v) for k, v in widget.items() if k in ANIMABLE_STYLES] + for prop, value_range in props: + # prop is the style property, value_range is a dict with from: and to: values + validator = ANIMABLE_STYLES[prop] + from_value = await _process_arg(validator, value_range[CONF_FROM]) + to_value = await _process_arg(validator, value_range[CONF_TO]) + index = len(froms) + if len(from_value) == 1: + value = f"values[{index}]" + else: + value = f"lv_color_make(values[{index}+0], values[{index}+1], values[{index}+2])" + w.set_style(prop, literal(value), 0) + # The value arrays are extended by 1 item for scalar properties, 3 for colors + froms.extend(from_value) + tos.extend(to_value) + + data_size = len(froms) + loop = animation[CONF_LOOP] + start_delay = await lv_milliseconds.process(animation.get(CONF_START_DELAY)) + var = cg.new_Pvariable( + animation[CONF_ID], + TemplateArguments(data_size, animation[CONF_AUTO_START]), + await ctx.get_lambda(), + froms, + tos, + ) + for timing in animation[CONF_TIMING]: + timing_id = timing[CONF_ID] + args = sorted( + [(k, v) for k, v in timing.items() if k not in [CONF_ID, CONF_TYPE]] + ) + args = [v for k, v in args] + timing_var = cg.new_Pvariable(timing_id, *args) + cg.add(var.add_timing(timing_var)) + + if start_delay: + cg.add(var.set_start_delay(start_delay)) + if loop: + cg.add(var.set_loop(loop)) + cg.add( + var.set_duration(await lv_milliseconds.process(animation[CONF_DURATION])) + ) + await cg.register_component(var, animation) + + +async def add_animation_triggers(config): + async def add_triggers(animation: MockObj, event: str, config: dict) -> None: + for conf in config: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger, [], conf) + async with LambdaContext([]) as context: + lv_add(trigger.trigger()) + lv_add( + getattr( + animation, + f"add_{event}_callback", + )(await context.get_lambda()) + ) + + for animation in config: + var = await cg.get_variable(animation[CONF_ID]) + await add_triggers(var, CONF_ON_START, animation.get(CONF_ON_START, [])) + await add_triggers(var, CONF_ON_STOP, animation.get(CONF_ON_STOP, [])) + + +@automation.register_action( + "lvgl.animation.start", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + cv.Optional(CONF_DURATION): lv_milliseconds, + cv.Optional(CONF_START_DELAY): lv_milliseconds, + cv.Optional(CONF_LOOP): cv.boolean, + }, + key=CONF_ID, + ), + synchronous=True, +) +async def start_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + loop = config.get(CONF_LOOP) + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + if loop is not None: + context.add(anim_var.set_loop(loop)) + if (duration := config.get(CONF_DURATION)) is not None: + context.add( + anim_var.set_duration(await lv_milliseconds.process(duration)) + ) + if (start_delay := config.get(CONF_START_DELAY)) is not None: + context.add( + anim_var.set_start_delay(await lv_milliseconds.process(start_delay)) + ) + context.add(anim_var.start()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var + + +@automation.register_action( + "lvgl.animation.stop", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + }, + key=CONF_ID, + ), + synchronous=True, +) +async def stop_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + context.add(anim_var.stop()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15e593b3f64..5c75269c648 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -214,11 +214,14 @@ class LValidator: has `process()` to convert a value during code generation """ - def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None): + def __init__( + self, validator, rtype: MockObj, retmapper=None, requires=None, animatable=False + ): self.validator = validator self.rtype = rtype self.retmapper = retmapper self.requires = requires + self.animatable = animatable def __call__(self, value): if self.requires: @@ -228,7 +231,10 @@ class LValidator: return self.validator(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: if value is None: return None @@ -236,11 +242,15 @@ class LValidator: # Local import to avoid circular import from .lvcode import get_lambda_context_args - args = args or get_lambda_context_args() + # `args is None` means "inherit the enclosing lambda context"; an explicit + # empty list means "no parameters" and must be preserved as-is. + if args is None: + args = get_lambda_context_args() - return call_lambda( - await cg.process_lambda(value, args, return_type=self.rtype) - ) + lamb = await cg.process_lambda(value, args, return_type=self.rtype) + if raw_lambda: + return lamb + return call_lambda(lamb) if self.retmapper is not None: return self.retmapper(value) if isinstance(value, ID): @@ -751,6 +761,7 @@ CONF_ON_DRAW_END = "on_draw_end" CONF_ON_PAUSE = "on_pause" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" +CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 27cbfff6942..d31c8324dba 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -60,6 +60,7 @@ opacity = LValidator( opacity_validator, lv_opa_t, retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0), + animatable=True, ) COLOR_NAMES = { @@ -223,35 +224,33 @@ def color(value): ) -def color_retmapper(value): - if isinstance(value, cv.Lambda): - return cv.returning_lambda(value) +def get_component_colors(value): if isinstance(value, str) and value in COLOR_NAMES: value = COLOR_NAMES[value] if isinstance(value, int): - return literal( - f"lv_color_make({(value >> 16) & 0xFF}, {(value >> 8) & 0xFF}, {value & 0xFF})" - ) + return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: r, g, b, _ = from_rgbw(cval) - return literal(f"lv_color_make({r}, {g}, {b})") + return r, g, b raise AssertionError(f"Unhandled lv_color value: {value!r}") -def option_string(value): - value = cv.string(value).strip() - if value.find("\n") != -1: - raise cv.Invalid("Options strings must not contain newlines") - return value +def color_retmapper(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + r, g, b = get_component_colors(value) + return literal(f"lv_color_make({r}, {g}, {b})") class LvColor(LValidator): def __init__(self): - super().__init__(color, ty.lv_color_t, retmapper=color_retmapper) + super().__init__( + color, ty.lv_color_t, retmapper=color_retmapper, animatable=True + ) def __getattr__(self, item): if item in COLOR_NAMES: @@ -262,6 +261,13 @@ class LvColor(LValidator): lv_color = LvColor() +def option_string(value): + value = cv.string(value).strip() + if value.find("\n") != -1: + raise cv.Invalid("Options strings must not contain newlines") + return value + + def pixels_or_percent_validator(value): """A length in one axis - either a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: @@ -277,6 +283,7 @@ pixels_or_percent = LValidator( pixels_or_percent_validator, lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), + animatable=True, ) @@ -315,10 +322,10 @@ def angle(value): # Validator for angles in LVGL expressed in 1/10 degree units. -lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable=True) # Validator for angles in LVGL expressed in whole degrees -lv_angle_degrees = LValidator(angle, uint32, retmapper=int) +lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) @schema_extractor("one_of") @@ -410,7 +417,10 @@ class TextValidator(LValidator): return super().__call__(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -455,13 +465,18 @@ class TextValidator(LValidator): return value # Either a std::string or a lambda call returning that. We need const char* return MockObj(f"({value}).c_str()") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_text = TextValidator() lv_float = LValidator(cv.float_, cg.float_) -lv_int = LValidator(cv.int_, cg.int_) -lv_positive_int = LValidator(cv.positive_int, cg.int_) +lv_positive_float = LValidator(cv.positive_float, cg.float_) +lv_zero_to_one_float = LValidator(cv.zero_to_one_float, cg.float_) +lv_int = LValidator(cv.int_, cg.int_, animatable=True) +lv_positive_int = LValidator(cv.positive_int, cg.int_, animatable=True) +lv_brightness = LValidator( + cv.percentage, cg.float_, retmapper=lambda x: int(x * 255), animatable=True +) def _percentage_validator(value): @@ -508,12 +523,17 @@ class LvFont(LValidator): # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* super().__init__(validator, Font.operator("ptr")) - async def process(self, value, args=()): + async def process( + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, + ): if is_lv_font(value): return literal(f"&lv_font_{value}") if isinstance(value, str): return literal(f"{value}") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_font = LvFont() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 509d5cc7824..61efe385e69 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -67,6 +67,7 @@ lv_obj_t = LvType("lv_obj_t") lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") +LvAnimation = lvgl_ns.class_("LvAnimation", cg.Component) lv_event_t = LvType("lv_event_t") RotationType = lvgl_ns.enum("RotationType") lv_point_t = cg.global_ns.struct("lv_point_t") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 639508a7b2e..bdb0f27f453 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL +#define USE_LVGL_ANIMATION #define USE_LVGL_ANIMIMG #define USE_LVGL_ARC #define USE_LVGL_BINARY_SENSOR diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py new file mode 100644 index 00000000000..1a2cde632c5 --- /dev/null +++ b/tests/component_tests/lvgl/test_animation.py @@ -0,0 +1,201 @@ +"""Tests for the LVGL animation schema and configuration validation.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.lvgl.animation import ( + ANIMABLE_STYLES, + ANIMATION_SCHEMA, + TIMING_SCHEMA, + from_to, + literal_color, +) +from esphome.components.lvgl.defines import LValidator +from esphome.core import Lambda + + +def _animation(**overrides) -> dict: + """A minimal valid animation config, with optional overrides applied.""" + config = { + "id": "anim_id", + "widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}], + } + config.update(overrides) + return config + + +# --------------------------------------------------------------------------- +# Animatable property set +# --------------------------------------------------------------------------- + + +class TestAnimableStyles: + def test_all_entries_are_animatable_validators(self) -> None: + """Every animatable style must be an LValidator marked animatable.""" + assert ANIMABLE_STYLES + assert all( + isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values() + ) + + def test_known_animatable_present(self) -> None: + for prop in ("x", "y", "opa", "bg_color", "transform_rotation"): + assert prop in ANIMABLE_STYLES + + def test_non_animatable_absent(self) -> None: + # width/height set size but are not animatable; layout/padding never are. + for prop in ("width", "height", "radius", "pad_all", "align"): + assert prop not in ANIMABLE_STYLES + + +# --------------------------------------------------------------------------- +# Animation schema +# --------------------------------------------------------------------------- + + +class TestAnimationSchema: + def test_defaults(self) -> None: + config = ANIMATION_SCHEMA(_animation()) + assert config["duration"].total_milliseconds == 5000 + assert config["start_delay"].total_milliseconds == 0 + assert config["auto_start"] is False + assert config["loop"] is False + assert config["timing"] == [] + + def test_values_preserved(self) -> None: + config = ANIMATION_SCHEMA( + _animation(duration="2s", start_delay="250ms", auto_start=True, loop=True) + ) + assert config["duration"].total_milliseconds == 2000 + assert config["start_delay"].total_milliseconds == 250 + assert config["auto_start"] is True + assert config["loop"] is True + + def test_id_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]}) + + def test_widgets_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"id": "anim_id"}) + + def test_multiple_properties_and_widgets(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "x": {"from": 0, "to": 100}, + "opa": {"from": "0%", "to": "100%"}, + }, + {"id": "w2", "y": {"from": 10, "to": 50}}, + ] + ) + ) + assert len(config["widgets"]) == 2 + + def test_unknown_property_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA( + _animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}]) + ) + + +class TestAnimatedColorLiteral: + """A color animated via from/to must be a literal, not a lambda.""" + + def test_color_lambda_rejected_directly(self) -> None: + with pytest.raises(Invalid, match="lambda"): + literal_color(Lambda("return lv_color_hex(0xFF0000);")) + + def test_color_literal_accepted_directly(self) -> None: + # A literal color value validates without error. + literal_color(0xFF0000) + + def test_color_lambda_rejected_in_animation(self) -> None: + with pytest.raises((Invalid, MultipleInvalid), match="lambda"): + ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "text_color": { + "from": Lambda("return lv_color_hex(0xFF0000);"), + "to": 0x00FF00, + }, + } + ] + ) + ) + + def test_color_literals_accepted_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + def test_non_color_property_allows_lambda(self) -> None: + # Only colors are restricted; numeric properties may use lambdas. + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + +class TestFromTo: + def test_requires_both(self) -> None: + validator = from_to(lambda value: value) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"from": 1}) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"to": 1}) + + def test_accepts_both(self) -> None: + validator = from_to(lambda value: value) + assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2} + + +# --------------------------------------------------------------------------- +# Timing schema +# --------------------------------------------------------------------------- + + +class TestTimingSchema: + def test_round_trip_string(self) -> None: + assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + + def test_ease_in_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in_out") + assert result["type"] == "ease_in_out" + assert result["weight"] == pytest.approx(2.0) + + def test_ease_in_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) + assert result["weight"] == pytest.approx(3.0) + + def test_gravity_defaults(self) -> None: + result = TIMING_SCHEMA("gravity") + assert result["type"] == "gravity" + assert result["bounce"] == pytest.approx(0.5) + assert result["acceleration"] == pytest.approx(0.5) + + def test_gravity_custom(self) -> None: + result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8}) + assert result["bounce"] == pytest.approx(0.3) + assert result["acceleration"] == pytest.approx(0.8) + + def test_unknown_type_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "not_a_timing"}) + + def test_timing_list_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}]) + ) + types = [t["type"] for t in config["timing"]] + assert types == ["round_trip", "gravity"] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4ec4eb3bd62..4b18b998484 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,12 @@ lvgl: id: meter_arc_indicator start_value: 0 end_value: 180 + - lvgl.animation.start: + id: + - anim_slide + - anim_color + duration: 3s + loop: true on_invalidate_area: logger.log: Invalidate area on_resolution_change: @@ -97,6 +103,52 @@ lvgl: - obj: bg_color: 0x000000 bg_opa: cover + top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 50 + height: 50 + bg_color: 0xFF0000 + - label: + id: anim_label + text: anim + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: ease_in_out + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: 100 + y: + from: 0 + to: !lambda "return 80;" + opa: + from: 50% + to: 100% + - id: anim_color + duration: 2s + timing: + - round_trip + - type: gravity + bounce: 0.3 + acceleration: 0.8 + widgets: + - id: anim_label + text_color: + from: 0xFF0000 + to: color_id theme: dark_mode: true obj: @@ -199,6 +251,11 @@ lvgl: on_click: then: - lvgl.display.set_rotation: 0 + - lvgl.animation.stop: anim_slide + - lvgl.animation.stop: + id: + - anim_slide + - anim_color - lvgl.widget.hide: message_box - lvgl.style.update: id: style_test diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 6328648fe34..90cbb3c0a59 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -22,6 +22,36 @@ lvgl: displays: sdl0 rotation: 180 top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 40 + height: 40 + bg_color: 0xFF0000 + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: + - round_trip + - type: ease_in_out + weight: 3 + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: !lambda "return 100;" + opa: + from: 50% + to: 100% - id: lvgl_1 displays: sdl1 @@ -42,7 +72,14 @@ lvgl: - label: text: Click ME on_click: - logger.log: Clicked + then: + - logger.log: Clicked + - lvgl.animation.stop: + id: anim_slide + lvgl_id: lvgl_0 + - lvgl.animation.start: + id: anim_slide + lvgl_id: lvgl_0 font: - file: "gfonts://Roboto" From b787281388ff9edce49ef4c15ea396dd79cfe62a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:00:42 +1000 Subject: [PATCH 328/343] [lvgl] Add direct use of `mapping` (#15863) --- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/lv_validation.py | 70 +++++++++++++++++++++--- esphome/components/lvgl/schemas.py | 19 +++++++ esphome/components/lvgl/widgets/img.py | 17 +++++- tests/components/lvgl/common.yaml | 4 +- tests/components/lvgl/lvgl-package.yaml | 31 ++++++++++- 6 files changed, 128 insertions(+), 15 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 5c75269c648..480ba515d1e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -739,6 +739,7 @@ CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" CONF_HOME = "home" +CONF_IMAGE = "image" CONF_INDICATORS = "indicators" CONF_INITIAL_FOCUS = "initial_focus" CONF_SELECTED_DIGIT = "selected_digit" @@ -752,6 +753,7 @@ CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" +CONF_MAPPING = "mapping" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d31c8324dba..56ee3b47aff 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -22,9 +22,12 @@ from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType +from ..mapping import INDEX_TYPES, get_mapping_metadata from . import types as ty from .defines import ( CONF_END_VALUE, + CONF_IMAGE, + CONF_MAPPING, CONF_START_VALUE, CONF_TIME_FORMAT, LV_FONTS, @@ -375,21 +378,54 @@ def stop_value(value): return cv.int_range(0, 255)(value) -def image_validator(value): - value = cv.requires_component("image")(value) +def _image_validator(value): + if isinstance(value, dict) and CONF_MAPPING in value: + from .schemas import MAPPING_IMAGE_SCHEMA + + return MAPPING_IMAGE_SCHEMA(value) value = cv.use_id(Image_)(value) get_lv_images_used().add(value) add_lv_use("label") return value -lv_image = LValidator( - image_validator, - image.Image_.operator("ptr"), - requires="image", -) +class ImageValidator(LValidator): + def __init__(self): + super().__init__( + validator=_image_validator, + rtype=image.Image_.operator("ptr"), + requires=CONF_IMAGE, + ) + + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: + # Local import to avoid circular import at module level + from .lvcode import get_lambda_context_args + + args = args or get_lambda_context_args() + if isinstance(value, dict) and CONF_MAPPING in value: + mapping_id = value[CONF_MAPPING] + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index) + + return await super().process(value, args) + + +lv_image = ImageValidator() + lv_image_list = LValidator( - cv.ensure_list(image_validator), + cv.ensure_list(_image_validator), cg.std_vector.template(image.Image_.operator("ptr")), requires="image", ) @@ -440,6 +476,24 @@ class TextValidator(LValidator): f"(std::isfinite({arg_expr}) ? {sprintf_str} : {nanval})" ) return literal(sprintf_str) + if mapping_id := value.get(CONF_MAPPING): + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + if metadata.to_ != INDEX_TYPES["string"]: + raise ValueError( + f"Mapping {mapping_id} does not map to strings, cannot use in text" + ) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index).c_str() + if time_format := value.get(CONF_TIME_FORMAT): source = value[CONF_TIME] if isinstance(source, Lambda): diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index d7df6289071..13214d459db 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -3,6 +3,7 @@ from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation +from esphome.components.mapping import mapping_class from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( CONF_TEXT, CONF_TIME, CONF_TRIGGER_ID, + CONF_VALUE, CONF_X, CONF_Y, ) @@ -31,6 +33,7 @@ from esphome.schema_extractors import ( from . import defines as df, lv_validation as lvalid from .defines import ( CONF_EXT_CLICK_AREA, + CONF_MAPPING, CONF_SCROLL_DIR, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, @@ -89,6 +92,20 @@ PRINTF_TEXT_SCHEMA = cv.All( validate_printf, ) +MAPPING_TEXT_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + +MAPPING_IMAGE_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + def _validate_text(value): """ @@ -100,6 +117,8 @@ def _validate_text(value): if isinstance(value, dict): if CONF_TIME_FORMAT in value: return TIME_TEXT_SCHEMA(value) + if CONF_MAPPING in value: + return MAPPING_TEXT_SCHEMA(value) return PRINTF_TEXT_SCHEMA(value) return cv.templatable(cv.string)(value) diff --git a/esphome/components/lvgl/widgets/img.py b/esphome/components/lvgl/widgets/img.py index 8a046fea334..da81ab77377 100644 --- a/esphome/components/lvgl/widgets/img.py +++ b/esphome/components/lvgl/widgets/img.py @@ -1,3 +1,5 @@ +from esphome.components.image import INSTANCE_TYPE as IMAGE_TYPE +from esphome.components.mapping import get_mapping_metadata import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -9,7 +11,9 @@ from esphome.const import ( from ..defines import ( CONF_ANTIALIAS, + CONF_IMAGE, CONF_MAIN, + CONF_MAPPING, CONF_PIVOT_X, CONF_PIVOT_Y, CONF_SCALE, @@ -21,8 +25,6 @@ from ..types import lv_image_t from . import Widget, WidgetType from .label import CONF_LABEL -CONF_IMAGE = "image" - BASE_IMG_SCHEMA = cv.Schema( { cv.Optional(CONF_PIVOT_X): size, @@ -69,5 +71,16 @@ class ImgType(WidgetType): for prop, validator in BASE_IMG_SCHEMA.schema.items(): await w.set_property(prop, config, processor=validator) + def final_validate(self, widget, update_config, widget_config, path): + src = update_config.get(CONF_SRC) + if isinstance(src, dict) and CONF_MAPPING in src: + mapping_id = src[CONF_MAPPING] + metadata = get_mapping_metadata(mapping_id.id) + if str(metadata.to_.data_type) != str(IMAGE_TYPE): + raise cv.Invalid( + f"Mapping '{mapping_id}' does not map to an image type, but '{metadata.to_.data_type}'", + path=path + [CONF_SRC, CONF_MAPPING], + ) + img_spec = ImgType() diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index f500002f401..b4d5fe03872 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -91,8 +91,8 @@ binary_sensor: animation: move_right time: 600ms - platform: lvgl - id: button_checker - name: LVGL button + id: common_button_checker + name: Common button widget: spin_up on_state: then: diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4b18b998484..d6cd3821f92 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -263,6 +263,9 @@ lvgl: bg_opa: !lambda return 0.5; - lvgl.image.update: id: lv_image + src: + mapping: image_map + value: !lambda return round(1.0); scale: !lambda return 512; rotation: !lambda return 100; pivot_x: !lambda return 20; @@ -388,9 +391,16 @@ lvgl: text_font: montserrat_40 border_post: true on_press: - lvgl.label.update: - id: hello_label - text: Goodbye + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: !lambda return 2; + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: 2 on_click: then: - lvgl.animimg.stop: anim_img @@ -1496,6 +1506,21 @@ image: invert_alpha: true transparency: alpha_channel +mapping: + - id: image_map + from: int + to: image + entries: + 0: cat_image + 1: dog_image + - id: lvgl_string_map + from: int + to: string + entries: + 0: "First" + 1: "Second" + 2: "Third" + color: - id: light_blue hex: "3340FF" From e7933a5387fea9a67bd5a99cd7687e5f10f556f9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:06:38 -0400 Subject: [PATCH 329/343] Bump bundled esphome-device-builder to 1.3.1 (#17450) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 3a7d5e8bbe3..db2e01742ca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.3.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.3.1 RUN \ platformio settings set enable_telemetry No \ From ce468952708d24ea2094758ac9640f1be499922d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:30:49 +1000 Subject: [PATCH 330/343] [uart][usb_uart] Implement runtime settings update (#16990) Co-authored-by: Claude Opus 4.8 Co-authored-by: Keith Burzinski --- esphome/components/uart/uart_component.h | 4 +- .../components/uart/uart_component_esp8266.h | 2 +- .../components/uart/uart_component_esp_idf.h | 2 +- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 6 + esphome/components/usb_uart/ch34x.cpp | 184 +++++++------- esphome/components/usb_uart/cp210x.cpp | 46 ++-- esphome/components/usb_uart/ft23xx.cpp | 236 ++++++------------ esphome/components/usb_uart/pl2303.cpp | 184 +++++++------- esphome/components/usb_uart/usb_uart.cpp | 208 +++++++++++---- esphome/components/usb_uart/usb_uart.h | 66 +++-- esphome/components/weikai/weikai.h | 9 + tests/components/mitsubishi_cn105/common.h | 3 + tests/components/uart/common.h | 3 + 13 files changed, 534 insertions(+), 419 deletions(-) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index afd3ad57774..3e525317917 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -178,7 +178,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(bool dump_config){}; + virtual void load_settings(bool dump_config) = 0; /** * Load the UART settings. @@ -190,7 +190,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(){}; + void load_settings() { this->load_settings(true); } #endif // USE_ESP8266 || USE_ESP32 #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index ee3be3cd3a1..469885b6b6c 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -75,7 +75,7 @@ class ESP8266UartComponent final : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 3b86368797d..649dd3aa461 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -50,7 +50,7 @@ class IDFUARTComponent final : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 2251c600e7e..8e71fc61b22 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -84,6 +84,12 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteddefer([this, error_code = status.error_code]() { - ESP_LOGE(TAG, "CH34x chip detection failed: %s", esp_err_to_name(error_code)); - this->apply_line_settings_(); - }); - return; - } - CH34xChipType chiptype = CHIP_UNKNOWN; - uint8_t num_ports = 1; - for (const auto &e : CH34X_TABLE) { - if (e.pid != this->pid_) - continue; - if (e.match != 0xFF && (status.data[e.byte_idx] & e.mask) != e.match) - continue; - chiptype = e.chiptype; - num_ports = e.num_ports; +bool USBUartTypeCH34X::config_device_step(uint8_t step, bool ok, const uint8_t *response) { + if (step == 0) { + // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes + // used to distinguish CH34x variants sharing the same PID. + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + return true; + } + // step 1: parse the chip-version response (falling back to "unknown" on failure). + if (!ok) { + ESP_LOGE(TAG, "CH34x chip detection failed"); + return false; + } + CH34xChipType chiptype = CHIP_UNKNOWN; + uint8_t num_ports = 1; + for (const auto &e : CH34X_TABLE) { + if (e.pid != this->pid_) + continue; + if (e.match != 0xFF && (response[e.byte_idx] & e.mask) != e.match) + continue; + chiptype = e.chiptype; + num_ports = e.num_ports; + break; + } + // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) + if (chiptype == CHIP_CH344L && (response[0] & 0xF0) != 0x40) + chiptype = CHIP_CH344L_V2; + const char *name = "unknown"; + for (const auto &e : CH34X_TABLE) { + if (e.chiptype == chiptype) { + name = e.name; break; } - // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) - if (chiptype == CHIP_CH344L && (status.data[0] & 0xF0) != 0x40) - chiptype = CHIP_CH344L_V2; - const char *name = "unknown"; - for (const auto &e : CH34X_TABLE) { - if (e.chiptype == chiptype) { - name = e.name; - break; - } - } - this->defer([this, chiptype, num_ports, name]() { - this->chiptype_ = chiptype; - this->chip_name_ = name; - this->num_ports_ = num_ports; - ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); - this->apply_line_settings_(); - }); - }; - // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes - // used to distinguish CH34x variants sharing the same PID. - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, cb, {0, 0, 0, 0, 0, 0, 0, 0}); + } + this->chiptype_ = chiptype; + this->chip_name_ = name; + this->num_ports_ = num_ports; + ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); + return false; } void USBUartTypeCH34X::dump_config() { @@ -98,67 +95,64 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -void USBUartTypeCH34X::apply_line_settings_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); +bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + uint8_t cmd = 0xA1 + channel->index_; + if (channel->index_ >= 2) + cmd += 0xE; + switch (step) { + case 0: { + uint8_t divisor = 7; + uint32_t clk = 12000000; + + auto baud_rate = channel->baud_rate_; + if (baud_rate < 256000) { + if (baud_rate > 6000000 / 255) { + divisor = 3; + clk = 6000000; + } else if (baud_rate > 750000 / 255) { + divisor = 2; + clk = 750000; + } else if (baud_rate > 93750 / 255) { + divisor = 1; + clk = 93750; + } else { + divisor = 0; + clk = 11719; + } } - }; - - uint8_t divisor = 7; - uint32_t clk = 12000000; - - auto baud_rate = channel->baud_rate_; - if (baud_rate < 256000) { - if (baud_rate > 6000000 / 255) { - divisor = 3; - clk = 6000000; - } else if (baud_rate > 750000 / 255) { - divisor = 2; - clk = 750000; - } else if (baud_rate > 93750 / 255) { - divisor = 1; - clk = 93750; - } else { - divisor = 0; - clk = 11719; + ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); + auto factor = static_cast(clk / baud_rate); + if (factor == 0 || factor == 0xFF) { + ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); + return false; } - } - ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); - auto factor = static_cast(clk / baud_rate); - if (factor == 0 || factor == 0xFF) { - ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); - channel->initialised_.store(false); - continue; - } - if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) - factor++; - factor = 256 - factor; + if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) + factor++; + factor = 256 - factor; - uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) - value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; + uint16_t value = 0xC0; + if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + value |= 4; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + break; + default: + value |= 8 | ((channel->parity_ - 1) << 4); + break; + } + value |= channel->data_bits_ - 5; + value <<= 8; + value |= 0x8C; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor); + return true; } - value |= channel->data_bits_ - 5; - value <<= 8; - value |= 0x8C; - uint8_t cmd = 0xA1 + channel->index_; - if (channel->index_ >= 2) - cmd += 0xE; - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); + case 1: + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + return true; + default: + return false; } - this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index c4edaed0386..2722ec8555e 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -97,29 +97,31 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeCP210X::enable_channels() { - // enable the channels - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } - }; - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; - line_control |= channel->data_bits_ << 8; - ESP_LOGD(TAG, "Line control value 0x%X", line_control); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_, - callback); - auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, - baud.get_data()); +bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). + if (reload) + step++; + switch (step) { + case 0: + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); + return true; + case 1: { + uint16_t line_control = channel->stop_bits_; + line_control |= static_cast(channel->parity_) << 4; + line_control |= channel->data_bits_ << 8; + ESP_LOGD(TAG, "Line control value 0x%X", line_control); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); + return true; + } + case 2: { + auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); + return true; + } + default: + return false; } - this->start_channels_(); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 25e4cc524fe..79aa107d725 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -112,40 +112,46 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, - uint16_t *index) { +struct FtdiConfig { + uint16_t value; + uint16_t ftdi_index; int best_baud; +}; + +static FtdiConfig ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index) { uint32_t encoded_divisor; + FtdiConfig config{}; + if (baudrate <= 0) { - return -1; + return config; } static constexpr uint32_t H_CLK = 120000000; static constexpr uint32_t C_CLK = 48000000; if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { if (baudrate * 10 > H_CLK / 0x3fff) { - best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ } else { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); + config.best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (uint16_t) (encoded_divisor & 0xFFFF); + config.value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (uint16_t) (encoded_divisor >> 8); - *index &= 0xFF00; - *index |= (channel_index + 1); + config.ftdi_index = (uint16_t) (encoded_divisor >> 8); + config.ftdi_index &= 0xFF00; + config.ftdi_index |= (channel_index + 1); } else { - *index = (uint16_t) (encoded_divisor >> 16); + config.ftdi_index = (uint16_t) (encoded_divisor >> 16); } - return best_baud; + return config; } static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { @@ -264,138 +270,6 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate_(channel); - } - }; - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Reset control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Baudrate %" PRIu32 " set, setting line properties...", channel->baud_rate_); - this->set_line_properties_(channel); - } - }; - if (baudrate == 0) { - baudrate = channel->baud_rate_; - } - uint16_t value = 0, ftdi_index = 0; - ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %" PRIu32 ", value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); - uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); - if (!ok) { - ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts_(channel); - }; - - uint16_t value = channel->data_bits_; - - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - - switch (channel->stop_bits_) { - case UART_CONFIG_STOP_BITS_1: - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } - - value |= (0x00 << 14); - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); - channel->initialised_.store(true); - this->start_input(channel); - uint8_t next_index = channel->index_ + 1; - if (next_index < this->channels_.size()) { - USBUartChannel *next_channel = this->channels_[next_index]; - ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset_(next_channel); - return; - } else { - ESP_LOGI(TAG, "All channels configured"); - } - }; - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { if (!channel->initialised_.load()) return; @@ -467,16 +341,68 @@ void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { channel->input_buffer_.clear(); } -void USBUartTypeFT23XX::enable_channels() { - if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset_(this->channels_[0]); - } - - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - channel->input_started_.store(false); - channel->output_started_.store(false); +bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios + // path only re-applies baud + line properties and does not re-assert DTR/RTS. + if (reload) + step++; + switch (step) { + case 0: // SIO reset (init only) + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + case 1: { // set baudrate + auto config = ftdi_convert_baudrate(channel->baud_rate_, this->chip_type_, channel->index_); + uint16_t usb_index = (config.ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + ESP_LOGD(TAG, "Baudrate: %u, value=0x%04X, ftdi_index=0x%04X", (unsigned) channel->baud_rate_, config.value, + config.ftdi_index); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, config.value, usb_index); + return true; + } + case 2: { // set line properties (data bits / parity / stop bits) + uint16_t value = channel->data_bits_; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + switch (channel->stop_bits_) { + default: // 1 bit + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + value |= (0x00 << 14); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + } + case 3: // set modem control DTR+RTS (init only) + if (reload) + return false; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + default: + return false; } } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 134c51198df..3c7ecd9a83d 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -200,100 +200,114 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypePL2303::enable_channels() { - if (this->channels_.empty()) - return; +// Vendor init sequence for non-HXN chips (mirrors pl2303_startup in the Linux driver): +// read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, write 0x0404=1, +// read 0x8484, read 0x8383, write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+). +// The final entry's wIndex is patched at runtime depending on the chip type. +struct Pl2303InitStep { + uint8_t type; + uint8_t request; + uint16_t value; + uint16_t index; + bool read; // reads need a 1-byte buffer to set wLength=1 so the IN data stage runs +}; +static const Pl2303InitStep PL2303_INIT[] = { + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 0, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 1, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0, 1, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 1, 0, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 2, 0, false}, +}; +static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); - auto *channel = this->channels_[0]; +bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); - usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { - if (!status.success) - ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); - }; - - // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): - // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, - // write 0x0404=1, read 0x8484, read 0x8383, - // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) - if (!is_hxn) { - uint8_t req = VENDOR_READ_REQUEST; - uint8_t wreq = VENDOR_WRITE_REQUEST; - - // Fire-and-forget vendor reads: result discarded, chip requires this sequence. - // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + // Vendor init burst runs only on full init for non-HXN chips. + uint8_t init_count = (!reload && !is_hxn) ? PL2303_INIT_COUNT : 0; + if (step < init_count) { + const auto &e = PL2303_INIT[step]; + uint16_t index = (step == PL2303_INIT_COUNT - 1) ? (is_legacy ? 0x24 : 0x44) : e.index; + this->config_transfer_(e.type, e.request, e.value, index, + e.read ? std::vector{0} : std::vector{}); + return true; } + step -= init_count; - // Build 7-byte line coding structure: - // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits - uint8_t line_coding[7] = {}; - uint32_t baud = channel->get_baud_rate(); - - // Choose baud encoding based on chip type - uint32_t nearest = nearest_supported_baud(baud); - if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { - encode_baud_direct(line_coding, baud); - } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { - encode_baud_divisor_alt(line_coding, baud); - } else { - encode_baud_divisor(line_coding, baud); - } - - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } - - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } - - // Data bits - line_coding[6] = channel->get_data_bits(); - - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], - line_coding[5], line_coding[6]); - - std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; - this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + switch (step) { + case 0: { + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); - // Assert DTR + RTS - this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } - this->start_channels_(); + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); + return true; + } + case 1: + // Assert DTR + RTS (init only) + if (reload) + return false; + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + return true; + default: + return false; + } } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a995e93e15e..482b209a3fc 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -6,6 +6,7 @@ #include "esphome/core/application.h" #include +#include namespace esphome::usb_uart { @@ -213,6 +214,7 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { void USBUartComponent::setup() { USBClient::setup(); } void USBUartComponent::loop() { bool had_work = this->process_usb_events_(); + had_work |= this->run_config_machine_(); // Process USB data from the lock-free queue UsbDataChunk *chunk; @@ -489,60 +491,182 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -void USBUartTypeCdcAcm::enable_channels() { +bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - // Configure the bridge's UART parameters. A USB-UART bridge will not forward data - // at the correct speed until SET_LINE_CODING is sent; without it the UART may run - // at an indeterminate default rate so the NCP receives garbled bytes and never - // sends RSTACK. - uint32_t baud = channel->baud_rate_; - std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits - }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); - this->control_transfer( - CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, - [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_LINE_CODING failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_LINE_CODING OK"); - } - }, - line_coding); - // Assert DTR+RTS to signal DTE is present. - this->control_transfer(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number, [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_CONTROL_LINE_STATE failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE (DTR+RTS) OK"); - } - }); + switch (step) { + case 0: { + // Configure the bridge's UART parameters. A USB-UART bridge will not forward data + // at the correct speed until SET_LINE_CODING is sent; without it the UART may run + // at an indeterminate default rate so the NCP receives garbled bytes and never + // sends RSTACK. + uint32_t baud = channel->baud_rate_; + std::vector line_coding = { + static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), + static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop + static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + static_cast(channel->data_bits_), // bDataBits + }; + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, + (unsigned) channel->parity_, channel->data_bits_); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, + line_coding); + return true; + } + case 1: + // Assert DTR+RTS to signal DTE is present (init only). + if (reload) + return false; + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, + channel->cdc_dev_.interrupt_interface_number); + return true; + default: + return false; } - this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; +void USBUartComponent::enable_channels() { + this->cfg_single_ = nullptr; + this->cfg_pending_reload_ = nullptr; + this->cfg_channel_idx_ = 0; + this->start_config_(false); +} + +void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { + if (this->cfg_active_) { + // A config sequence is already running. Defer this reload until it finishes to preserve + // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an + // in-flight callback complete against fresh state). The pending slot coalesces multiple + // requests; the channel's live settings are read when the reload eventually runs. + // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. + this->cfg_pending_reload_ = channel; + return; + } + this->cfg_single_ = channel; + this->start_config_(true); +} + +void USBUartComponent::start_config_(bool reload) { + this->cfg_reload_ = reload; + this->cfg_device_phase_ = !reload; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_active_ = true; + this->enable_loop(); +} + +void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data) { + this->cfg_done_.store(false); + // The completion callback runs in the USB-task context: it only records the result and + // wakes the loop. The next transfer is issued from run_config_machine_() on the loop thread. + bool submitted = this->control_transfer( + type, request, value, index, + [this](const usb_host::TransferStatus &status) { + this->cfg_ok_ = status.success; + if (!status.success) { + ESP_LOGW(TAG, "Config control transfer failed: %s", esp_err_to_name(status.error_code)); + } else if (status.data_len > 0) { + memcpy(this->cfg_response_, status.data, std::min(status.data_len, sizeof(this->cfg_response_))); + } + // Release: publishes cfg_ok_/cfg_response_ before the loop observes cfg_done_. + this->cfg_done_.store(true, std::memory_order_release); + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + }, + data); + if (!submitted) { + // Submission failed (e.g. no free transfer request). No callback will fire, so synthesize + // a failed completion here so the state machine advances/aborts instead of hanging. + ESP_LOGW(TAG, "Config control transfer submit failed"); + this->cfg_ok_ = false; + this->cfg_done_.store(true, std::memory_order_release); + } +} + +bool USBUartComponent::run_config_machine_() { + if (!this->cfg_active_) + return false; + + if (this->cfg_in_flight_) { + // Acquire: pairs with the release in config_transfer_'s callback. + if (!this->cfg_done_.load(std::memory_order_acquire)) + return false; // still waiting; the callback will re-wake the loop (no busy spin) + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_step_++; + } + + // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing + // cfg_done_ with acquire ordering), so it is safe to read. + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + + // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. + if (this->cfg_device_phase_) { + if (this->config_device_step(this->cfg_step_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + this->cfg_device_phase_ = false; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + } + + USBUartChannel *channel = + this->cfg_single_ != nullptr + ? this->cfg_single_ + : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); + + if (channel != nullptr && channel->initialised_.load()) { + if (!this->cfg_ok_) { + // A previous step in this channel's sequence failed. Abort the rest. On a full init, + // mark the channel uninitialised so data flow isn't started on a misconfigured channel; + // on a reload, leave the already-working channel as it was. + if (!this->cfg_reload_) + channel->initialised_.store(false); + } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + } + + // Channel finished (or aborted). On full init, kick off data flow if still initialised. + if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); } + + // Advance to the next channel (or finish). + this->cfg_step_ = 0; + this->cfg_ok_ = true; + if (this->cfg_single_ != nullptr) { + this->cfg_active_ = false; + this->cfg_single_ = nullptr; + } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { + this->cfg_active_ = false; + } + + // If the machine just went idle and a reload was requested while it was busy, start it now. + if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { + this->cfg_single_ = this->cfg_pending_reload_; + this->cfg_pending_reload_ = nullptr; + this->start_config_(true); + } + return true; +} + +void USBUartChannel::load_settings(bool /*dump_config*/) { + // The per-channel control transfers already log their values at debug level. + this->parent_->apply_channel_settings(this); } } // namespace esphome::usb_uart diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 6d60809b386..5bb4c977969 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -146,7 +146,9 @@ class USBUartChannel final : public uart::UARTComponent, public Parentedinput_buffer_.get_available(); } bool is_connected() override { return this->initialised_.load(); } uart::UARTFlushResult flush() override; - void check_logger_conflict() override {} + // Re-apply the current line settings (baud, parity, etc) to this already-open channel. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience void set_parity(UARTParityOptions parity) { this->parity_ = parity; } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } @@ -160,6 +162,7 @@ class USBUartChannel final : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; LockFreeQueue output_queue_; @@ -195,6 +198,12 @@ class USBUartComponent : public usb_host::USBClient { virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Begin configuring all channels (full initialisation). Called from on_connected(). + void enable_channels(); + // Re-apply line settings to a single, already-open channel (used by + // USBUartChannel::load_settings()). + void apply_channel_settings(USBUartChannel *channel); + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. // Default is a no-op; override in device-specific subclasses that need resync on overflow. virtual void on_rx_overflow(USBUartChannel *channel) {} @@ -206,7 +215,41 @@ class USBUartComponent : public usb_host::USBClient { EventPool chunk_pool_; protected: + // Issue one control transfer as part of the setup state machine. The completion + // callback (USB-task context) records the result/IN data, marks the step done and + // wakes the loop so run_config_machine_() advances on the loop thread. Call exactly + // once from config_step_()/config_device_step_() when issuing a step. + void config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data = {}); + // (Re)start the config state machine. reload=false runs full init over all channels; + // reload=true re-applies settings to cfg_single_ only. + void start_config_(bool reload); + // Advance the config state machine; called from loop(). Returns true if it did work. + bool run_config_machine_(); + + // Per-subclass per-channel settings sequence. For the given zero-based step, issue the + // next control transfer via config_transfer_() and return true, or return false when the + // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip + // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. + virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + // Optional one-time device-level setup run before the per-channel phase on init only + // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. + virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + std::vector channels_{}; + + // Config state machine + USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + uint8_t cfg_channel_idx_{0}; + uint8_t cfg_step_{0}; + bool cfg_active_{false}; + bool cfg_reload_{false}; + bool cfg_device_phase_{false}; + bool cfg_in_flight_{false}; + bool cfg_ok_{true}; }; class USBUartTypeCdcAcm : public USBUartComponent { @@ -217,11 +260,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - virtual void enable_channels(); - /// Resets per-channel transfer flags and posts the first bulk IN transfer. - /// Called by enable_channels() and by vendor-specific subclass overrides that - /// handle their own line-coding setup before starting data flow. - void start_channels_(); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -230,7 +269,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -238,11 +277,11 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: - void apply_line_settings_(); CH34xChipType chiptype_{CHIP_UNKNOWN}; const char *chip_name_{"unknown"}; uint8_t num_ports_{1}; @@ -257,12 +296,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; - - int reset_(USBUartChannel *channel); - int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties_(USBUartChannel *channel); - int set_dtr_rts_(USBUartChannel *channel); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -285,7 +319,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 6f38f583182..02a39d3c848 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -381,6 +381,15 @@ class WeikaiChannel : public uart::UARTComponent { /// we wait until all bytes are gone with a timeout of 100 ms uart::UARTFlushResult flush() override; +#if defined(USE_ESP8266) || defined(USE_ESP32) + /// @brief Re-apply the current line settings (baud, parity, etc) to the channel. + void load_settings(bool dump_config) override { + this->set_line_param_(); + this->set_baudrate_(); + } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience +#endif + protected: friend class WeikaiComponent; diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 798f7283f6c..45f7b65289d 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -37,6 +37,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) }; class TestableMitsubishiCN105 : public MitsubishiCN105 { diff --git a/tests/components/uart/common.h b/tests/components/uart/common.h index de3ea3029ef..5c4ba1130e7 100644 --- a/tests/components/uart/common.h +++ b/tests/components/uart/common.h @@ -32,6 +32,9 @@ class MockUARTComponent : public UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + MOCK_METHOD(void, load_settings, (bool dump_config), (override)); +#endif }; } // namespace esphome::uart::testing From 84f4fbeaa80900f52d2854511a85cafb227e0aab Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:41:36 +0200 Subject: [PATCH 331/343] [zigbee] Allow to combine and merge endpoints on esp32 (#17402) --- esphome/components/zigbee/__init__.py | 21 ++- esphome/components/zigbee/const.py | 3 + esphome/components/zigbee/const_esp32.py | 7 +- esphome/components/zigbee/const_zephyr.py | 2 +- esphome/components/zigbee/zigbee_ep_esp32.py | 157 +++++++++++++++--- esphome/components/zigbee/zigbee_esp32.py | 36 ++-- tests/components/zigbee/common_esp32.yaml | 12 +- .../zigbee/test-router.esp32-c6-idf.yaml | 7 + 8 files changed, 190 insertions(+), 55 deletions(-) create mode 100644 tests/components/zigbee/test-router.esp32-c6-idf.yaml diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 444012bcd8b..775fb351407 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -18,10 +18,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const import ( + CONF_ENDPOINT, + CONF_MAX_EP_NUMBER, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -31,7 +34,7 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_MAX_EP_NUMBER, + CONF_MAX_EP_NUMBER_ZEPHYR, CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, @@ -71,7 +74,17 @@ BASE_SCHEMA = cv.Schema( cv.requires_component("esp32"), _check_report_deprecation, cv.enum(REPORT, lower=True), - ) + ), + cv.Optional(CONF_ENDPOINT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.int_range(1, CONF_MAX_EP_NUMBER), + ), + cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.boolean, + ), } ) BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) @@ -148,8 +161,8 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: _LOGGER.warning( "Single endpoint requires ZHA or at leatst Zigbee2MQTT 2.8.0. For older versions of Zigbee2MQTT use multiple endpoints" ) - if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: - raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + if count > CONF_MAX_EP_NUMBER_ZEPHYR and not CORE.testing_mode: + raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER_ZEPHYR}") return config diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index dd36f815ab5..cfd23b9eb25 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -58,11 +58,14 @@ REPORT = { "default": report.ZIGBEE_REPORT_DEFAULT, } +CONF_ENDPOINT = "endpoint" +CONF_MAX_EP_NUMBER = 239 CONF_ON_JOIN = "on_join" CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 81a8fc52cda..bfc4d93d5b3 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -2,16 +2,13 @@ import esphome.codegen as cg DEVICE_TYPE = "device_type" ROLE = "role" -CONF_MAX_EP_NUMBER = 239 -CONF_NUM = "num" CONF_CLUSTERS = "clusters" CONF_ATTRIBUTES = "attributes" -CONF_ENDPOINT = "endpoint" CONF_CLUSTER = "cluster" SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" -KEY_BS_EP = "binary_sensor_ep" -KEY_SENSOR_EP = "sensor_ep" +KEY_ZIGBEE_EP = "zigbee_ep" +KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" DEVICE_ID = { "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 63d03c7952b..bf8e8287c4b 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,4 +1,4 @@ -CONF_MAX_EP_NUMBER = 8 +CONF_MAX_EP_NUMBER_ZEPHYR = 8 CONF_ZIGBEE_ID = "zigbee_id" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index f4efa7bf4e1..ca96e4364fb 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -2,16 +2,22 @@ from typing import Any import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE +from esphome.core import CORE -from .const import CONF_REPORT, REPORT +from .const import ( + CONF_MAX_EP_NUMBER, + CONF_REPORT, + CONF_USE_DEVICE_TYPE, + KEY_ZIGBEE, + REPORT, +) from .const_esp32 import ( - CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_MAX_EP_NUMBER, - CONF_NUM, DEVICE_TYPE, + KEY_ZIGBEE_EP, + KEY_ZIGBEE_EP_NO_NUM, ROLE, ) @@ -22,12 +28,12 @@ ep_configs: dict[str, dict[str, Any]] = { CONF_CLUSTERS: [ { CONF_ID: "BINARY_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["default"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -47,16 +53,15 @@ ep_configs: dict[str, dict[str, Any]] = { ], }, "analog_input": { - DEVICE_TYPE: "CUSTOM_ATTR", CONF_CLUSTERS: [ { CONF_ID: "ANALOG_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["default"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -78,22 +83,126 @@ ep_configs: dict[str, dict[str, Any]] = { } -def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: +def get_next_ep_num(eps: list[int]) -> int: + try: + ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0] + eps.append(ep_num) + except IndexError as e: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) from e + return ep_num + + +def merge_endpoint( + existing_ep: dict[str, Any], + ep_num: int | None, + ep: dict[str, Any], + use_type: bool | None, + skip_error: bool, +) -> bool: + add = True + existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] + for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: + if cl in existing_clusters: + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + add = False + break + if not add: + return False + if ( + use_type + and existing_ep.get(CONF_USE_DEVICE_TYPE) + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) + ): + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has a conflicting device type {existing_ep.get(DEVICE_TYPE, 'CUSTOM_ATTR')} and use_type is set for both." + ) + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + else: + existing_ep.pop(DEVICE_TYPE, None) + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + if existing_ep.get(CONF_USE_DEVICE_TYPE): + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + if ( + ep.get(DEVICE_TYPE) + and existing_ep.get(DEVICE_TYPE) + and ep[DEVICE_TYPE] != existing_ep[DEVICE_TYPE] + ): + if not skip_error: + raise cv.Invalid( + f"Endpoint {ep_num} has already a conflicting device type {existing_ep[DEVICE_TYPE]} and use_type is not set for both." + ) + return False + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + + +def create_ep(router: bool) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) # create dummy endpoint if list is empty - if not ep_list: + if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" if router: ep_type = "RANGE_EXTENDER" - ep_list = [ - { - DEVICE_TYPE: ep_type, - } - ] - # enumerate endpoints - for i, ep in enumerate(ep_list, 1): - ep[CONF_NUM] = i - if len(ep_list) > CONF_MAX_EP_NUMBER: - raise cv.Invalid( - f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." - ) - return ep_list + ep_dict[1] = {DEVICE_TYPE: ep_type} + if ep_list: + # merge endpoint with different clusters + ep_list_new: list[dict] = [] + for ep in ep_list: + added = False + for existing_ep in ep_list_new: + if merge_endpoint( + existing_ep, None, ep, ep.get(CONF_USE_DEVICE_TYPE), True + ): + added = True + break + if not added: + ep_list_new.append(ep) + + # Add endpoints with no number to the endpoint dict with a new number + eps = list(ep_dict.keys()) + for ep in ep_list_new: + ep_num = get_next_ep_num(eps) + ep_dict[ep_num] = ep + + # clear list so that it is not processed again + del zb_data[KEY_ZIGBEE_EP_NO_NUM] + + # Add default device type to endpoints that have none + for ep in ep_dict.values(): + if not ep.get(DEVICE_TYPE): + ep[DEVICE_TYPE] = "CUSTOM_ATTR" + + +def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if ep_num is None: + if use_type: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + ep_list.append(ep) + else: + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + if ep_num in ep_dict: + # check if the existing endpoint has same clusters + existing_ep = ep_dict[ep_num] + merge_endpoint(existing_ep, ep_num, ep, use_type, False) + else: + if use_type is not None: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_dict[ep_num] = ep diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index f19bc97be71..73dcd070295 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -35,9 +35,11 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, ZigbeeAttribute, @@ -45,18 +47,17 @@ from .const import ( from .const_esp32 import ( ATTR_TYPE, CLUSTER_ID, + CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_NUM, DEVICE_ID, DEVICE_TYPE, - KEY_BS_EP, - KEY_SENSOR_EP, + KEY_ZIGBEE_EP, ROLE, SCALE, ) -from .zigbee_ep_esp32 import create_ep, ep_configs +from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs _LOGGER = logging.getLogger(__name__) @@ -146,6 +147,7 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: raise cv.Invalid( f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" ) + create_ep(config.get(CONF_ROUTER)) return config @@ -199,18 +201,14 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: }, ) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, []) - sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: ep = copy.deepcopy(ep_configs["binary_input"]) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) - binary_sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -243,7 +241,7 @@ async def attributes_to_code( var.add_attr( ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], attr.get(CONF_MAX_LENGTH, 0), attr[CONF_VALUE], @@ -255,7 +253,7 @@ async def attributes_to_code( var, ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], ATTR_TYPE[attr[CONF_TYPE]], attr.get(SCALE, 1), @@ -287,9 +285,7 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # create endpoints zb_data = CORE.data.get(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, []) - binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) - ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER)) + ep_dict: dict[int, dict] = zb_data.get(KEY_ZIGBEE_EP, {}) # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) @@ -301,15 +297,15 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) - for ep in ep_list: - cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for ep_num, ep in ep_dict.items(): + cg.add(var.create_default_cluster(ep_num, DEVICE_ID[ep[DEVICE_TYPE]])) for cl in ep.get(CONF_CLUSTERS, []): cg.add( var.add_cluster( - ep[CONF_NUM], + ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], ) ) - await attributes_to_code(var, ep[CONF_NUM], cl) + await attributes_to_code(var, ep_num, cl) return var diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 787afc4476c..8e00e4471ec 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -8,10 +8,20 @@ binary_sensor: - platform: template name: "Garage Door Open 12" report: "force" + endpoint: 1 + +sensor: + - platform: template + name: "Temperature Sensor" + lambda: return 10.0; + device_class: temperature + unit_of_measurement: "°C" + endpoint: 1 + use_device_type: true zigbee: model: zigbee_test - router: true + router: false power_source: MAINS_SINGLE_PHASE on_join: then: diff --git a/tests/components/zigbee/test-router.esp32-c6-idf.yaml b/tests/components/zigbee/test-router.esp32-c6-idf.yaml new file mode 100644 index 00000000000..228fe331e5e --- /dev/null +++ b/tests/components/zigbee/test-router.esp32-c6-idf.yaml @@ -0,0 +1,7 @@ +zigbee: + model: zigbee_test + router: true + power_source: MAINS_SINGLE_PHASE + on_join: + then: + - logger.log: "Joined network" From 26f48ee9ea1626a4cc1b2bfdda440e699707dcc5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:21:23 -0400 Subject: [PATCH 332/343] [lvgl] Fix ImageValidator.process signature to match base (#17451) --- esphome/components/lvgl/lv_validation.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 56ee3b47aff..b588e865d2a 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -398,7 +398,10 @@ class ImageValidator(LValidator): ) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -419,7 +422,7 @@ class ImageValidator(LValidator): index = await metadata.from_.convert_value(index) return mapping_var.get(index) - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_image = ImageValidator() From dd0d0942f5867d0fa44352d6599ce66ba26d101f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:38:58 +1200 Subject: [PATCH 333/343] [image] Restructure into a platform component (#17416) --- .gitattributes | 2 + CODEOWNERS | 1 + esphome/components/animation/__init__.py | 118 +--- esphome/components/animation/image.py | 115 ++++ esphome/components/file/__init__.py | 1 + esphome/components/file/image.py | 315 +++++++++ esphome/components/image/__init__.py | 616 ++++++------------ esphome/components/online_image/__init__.py | 157 +---- esphome/components/online_image/image.py | 152 +++++ esphome/config.py | 12 + esphome/loader.py | 13 + script/build_language_schema.py | 11 - tests/component_tests/animation/__init__.py | 0 .../animation/config/anim.apng | Bin 0 -> 12626 bytes .../component_tests/animation/config/anim.gif | Bin 0 -> 9735 bytes .../config/animation_platform_test.yaml | 30 + .../animation/config/animation_test.yaml | 25 + tests/component_tests/animation/test_init.py | 81 +++ tests/component_tests/image/test_init.py | 446 +++++++++---- .../component_tests/online_image/__init__.py | 0 .../config/online_image_platform_test.yaml | 30 + .../config/online_image_test.yaml | 29 + .../component_tests/online_image/test_init.py | 76 +++ tests/components/animation/common.yaml | 19 +- tests/components/animation/validate.host.yaml | 16 + tests/components/file/common.yaml | 17 + tests/components/file/test.esp32-idf.yaml | 14 + tests/components/file/test.host.yaml | 9 + tests/components/image/common.yaml | 57 +- tests/components/image/test.esp8266-ard.yaml | 7 +- tests/components/image/test.host.yaml | 97 +-- .../image/validate-defaults.host.yaml | 25 + .../image/validate-grouped-single.host.yaml | 24 + .../image/validate-grouped.host.yaml | 25 + .../image/validate-single.host.yaml | 16 + tests/components/image/validate.host.yaml | 18 + tests/components/online_image/common.yaml | 29 +- .../online_image/validate.host.yaml | 22 + tests/unit_tests/test_config_normalization.py | 85 ++- 39 files changed, 1827 insertions(+), 883 deletions(-) create mode 100644 esphome/components/animation/image.py create mode 100644 esphome/components/file/__init__.py create mode 100644 esphome/components/file/image.py create mode 100644 esphome/components/online_image/image.py create mode 100644 tests/component_tests/animation/__init__.py create mode 100644 tests/component_tests/animation/config/anim.apng create mode 100644 tests/component_tests/animation/config/anim.gif create mode 100644 tests/component_tests/animation/config/animation_platform_test.yaml create mode 100644 tests/component_tests/animation/config/animation_test.yaml create mode 100644 tests/component_tests/animation/test_init.py create mode 100644 tests/component_tests/online_image/__init__.py create mode 100644 tests/component_tests/online_image/config/online_image_platform_test.yaml create mode 100644 tests/component_tests/online_image/config/online_image_test.yaml create mode 100644 tests/component_tests/online_image/test_init.py create mode 100644 tests/components/animation/validate.host.yaml create mode 100644 tests/components/file/common.yaml create mode 100644 tests/components/file/test.esp32-idf.yaml create mode 100644 tests/components/file/test.host.yaml create mode 100644 tests/components/image/validate-defaults.host.yaml create mode 100644 tests/components/image/validate-grouped-single.host.yaml create mode 100644 tests/components/image/validate-grouped.host.yaml create mode 100644 tests/components/image/validate-single.host.yaml create mode 100644 tests/components/image/validate.host.yaml create mode 100644 tests/components/online_image/validate.host.yaml diff --git a/.gitattributes b/.gitattributes index 1b3fd332b4f..8171cd910f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ # Normalize line endings to LF in the repository * text eol=lf *.png binary +*.gif binary +*.apng binary diff --git a/CODEOWNERS b/CODEOWNERS index 821d2e5e745..619fc140870 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi +esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/fs3000/* @kahrendt diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 9c9c7e38711..0df7c563136 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -1,114 +1,36 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after +# 2027.1.0. +# +# Animations are now a platform of the `image:` component (`platform: +# animation`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `animation:` key working during the +# deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_REPEAT -_LOGGER = logging.getLogger(__name__) +from .image import ANIMATION_CONFIG_SCHEMA, setup_animation -AUTO_LOAD = ["image"] +AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_START_FRAME = "start_frame" -CONF_END_FRAME = "end_frame" -CONF_FRAME = "frame" +DOMAIN = "animation" -animation_ns = cg.esphome_ns.namespace("animation") +LEGACY_REMOVAL_VERSION = "2027.1.0" -Animation_ = animation_ns.class_("Animation", espImage.Image_) - -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +_capture_legacy_entry, _warn_legacy_animation = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -CONFIG_SCHEMA = cv.All( - espImage.IMAGE_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Optional(CONF_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - }, - ), - espImage.validate_settings, -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_animation -NEXT_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -PREV_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -SET_FRAME_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(Animation_), - cv.Required(CONF_FRAME): cv.uint16_t, - } -) - - -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True -) -async def animation_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var - - -async def to_code(config): - ( - prog_arr, - width, - height, - image_type, - trans_value, - frame_count, - ) = await espImage.write_image(config, all_frames=True) - - var = cg.new_Pvariable( - config[CONF_ID], - prog_arr, - width, - height, - frame_count, - image_type, - trans_value, - ) - if loop_config := config.get(CONF_LOOP): - start = loop_config[CONF_START_FRAME] - end = loop_config.get(CONF_END_FRAME, frame_count) - count = loop_config.get(CONF_REPEAT, -1) - cg.add(var.set_loop(start, end, count)) +to_code = setup_animation diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py new file mode 100644 index 00000000000..95875fe2b01 --- /dev/null +++ b/esphome/components/animation/image.py @@ -0,0 +1,115 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_LOOP +from esphome.components.file.image import image_schema, write_image +from esphome.components.image import Image_, validate_settings +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_REPEAT +from esphome.types import ConfigType + +CODEOWNERS = ["@syndlex"] +AUTO_LOAD = ["file"] +DEPENDENCIES = ["display"] + +CONF_START_FRAME = "start_frame" +CONF_END_FRAME = "end_frame" +CONF_FRAME = "frame" + +animation_ns = cg.esphome_ns.namespace("animation") + +Animation_ = animation_ns.class_("Animation", Image_) + +# Actions +NextFrameAction = animation_ns.class_( + "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) +) +PrevFrameAction = animation_ns.class_( + "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) +) +SetFrameAction = animation_ns.class_( + "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +) + +ANIMATION_SCHEMA = image_schema(Animation_).extend( + { + cv.Optional(CONF_LOOP): cv.All( + { + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, + } + ), + }, +) + +# Shared schema used by both the (deprecated) top-level `animation:` key and the +# `image:` `platform: animation` entry. +ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings) + + +NEXT_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +PREV_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +SET_FRAME_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Animation_), + cv.Required(CONF_FRAME): cv.uint16_t, + } +) + + +@automation.register_action( + "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +) +async def animation_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if (frame := config.get(CONF_FRAME)) is not None: + template_ = await cg.templatable(frame, args, cg.uint16) + cg.add(var.set_frame(template_)) + return var + + +async def setup_animation(config: ConfigType) -> None: + ( + prog_arr, + width, + height, + image_type, + trans_value, + frame_count, + ) = await write_image(config, all_frames=True) + + var = cg.new_Pvariable( + config[CONF_ID], + prog_arr, + width, + height, + frame_count, + image_type, + trans_value, + ) + if loop_config := config.get(CONF_LOOP): + start = loop_config[CONF_START_FRAME] + end = loop_config.get(CONF_END_FRAME, frame_count) + count = loop_config.get(CONF_REPEAT, -1) + cg.add(var.set_loop(start, end, count)) + + +CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA + +to_code = setup_animation diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py new file mode 100644 index 00000000000..f70ffa95208 --- /dev/null +++ b/esphome/components/file/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py new file mode 100644 index 00000000000..9a7c762a79e --- /dev/null +++ b/esphome/components/file/image.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import logging +from pathlib import Path +import re + +from PIL import Image, UnidentifiedImageError + +from esphome import core, external_files +import esphome.codegen as cg +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.image import ( + CONF_INVERT_ALPHA, + CONF_OPAQUE, + CONF_TRANSPARENCY, + DOMAIN, + IMAGE_TYPE, + Image_, + ImageEncoder, + add_metadata, + get_image_type_enum, + get_transparency_enum, + is_svg_file, + validate_settings, + validate_transparency, + validate_type, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ICON, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_URL, +) +from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +_LOGGER = logging.getLogger(__name__) + +# If the MDI file cannot be downloaded within this time, abort. +IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds + +SOURCE_LOCAL = "local" +SOURCE_WEB = "web" + +SOURCE_MDI = "mdi" +SOURCE_MDIL = "mdil" +SOURCE_MEMORY = "memory" + +MDI_SOURCES = { + SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", + SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", + SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", +} + + +def compute_local_image_path(value) -> Path: + url = value[CONF_URL] if isinstance(value, dict) else value + h = hashlib.new("sha256") + h.update(url.encode()) + key = h.hexdigest()[:8] + # Downloaded files are cached under the shared `image` domain directory so + # the cache location is unaffected by which platform requested the file. + base_dir = external_files.compute_local_file_dir(DOMAIN) + return base_dir / key + + +def local_path(value): + value = value[CONF_PATH] if isinstance(value, dict) else value + return str(CORE.relative_config_path(value)) + + +def download_file(url, path): + external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + return str(path) + + +def download_gh_svg(value, source): + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + base_dir = external_files.compute_local_file_dir(DOMAIN) / source + path = base_dir / f"{mdi_id}.svg" + + url = MDI_SOURCES[source] + mdi_id + ".svg" + return download_file(url, path) + + +def download_image(value): + value = value[CONF_URL] if isinstance(value, dict) else value + return download_file(value, compute_local_image_path(value)) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + parts = value.strip().split(":") + if len(parts) == 2 and parts[0] in MDI_SOURCES: + match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) + if match is None: + raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") + return download_gh_svg(parts[1], parts[0]) + + if value.startswith(("http://", "https://")): + return download_image(value) + + value = cv.file_(value) + return local_path(value) + + +LOCAL_SCHEMA = cv.All( + { + cv.Required(CONF_PATH): cv.file_, + }, + local_path, +) + + +def mdi_schema(source): + def validate_mdi(value): + return download_gh_svg(value, source) + + return cv.All( + cv.Schema( + { + cv.Required(CONF_ICON): cv.string, + } + ), + validate_mdi, + ) + + +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.string, + }, + download_image, +) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + SOURCE_LOCAL: LOCAL_SCHEMA, + SOURCE_WEB: WEB_SCHEMA, + } + | {source: mdi_schema(source) for source in MDI_SOURCES}, + key=CONF_SOURCE, +) + + +OPTIONS_SCHEMA = { + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, + cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), +} + + +def image_schema(class_: MockObjClass = Image_) -> cv.Schema: + """Build the validation schema for a single file-backed image entry. + + Shared by the built-in ``file`` image platform and the ``animation`` + platform (which extends it). Platforms that source their pixels elsewhere + (e.g. ``online_image``) provide their own schema instead. + + :param class_: The declared C++ class for the generated image instance. + """ + return cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(class_), + cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + **OPTIONS_SCHEMA, + cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ) + + +def validate_image_final(config: ConfigType) -> ConfigType: + """Per-entry final validation, shared by file-backed image platforms. + + For LVGL 9 the default byte order for RGB565 images is little-endian, so + fill in that default when the user did not specify a byte order and warn + when big-endian was explicitly requested. + """ + if byte_order := config.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + config.get(CONF_FILE), + ) + else: + config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + return config + + +async def new_image(config: ConfigType) -> MockObj: + """Generate a single file-backed ``image::Image`` instance. + + Used by the built-in ``file`` platform; encodes the image data, registers + the C++ variable and records its metadata for other components to consume. + """ + prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + var = cg.new_Pvariable( + config[CONF_ID], prog_arr, width, height, image_type, trans_value + ) + add_metadata( + config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] + ) + return var + + +async def write_image(config, all_frames=False): + path = Path(config[CONF_FILE]) + if not path.is_file(): + raise core.EsphomeError(f"Could not load image file {path}") + + resize = config.get(CONF_RESIZE) + try: + if is_svg_file(path): + import resvg_py + + resize = resize or (None, None) + image_data = resvg_py.svg_to_bytes( + svg_path=str(path), width=resize[0], height=resize[1], dpi=100 + ) + + # Convert bytes to Pillow Image + image = Image.open(io.BytesIO(image_data)) + width, height = image.size + + else: + image = Image.open(path) + width, height = image.size + if resize: + # Preserve aspect ratio + new_width_max = min(width, resize[0]) + new_height_max = min(height, resize[1]) + ratio = min(new_width_max / width, new_height_max / height) + width, height = int(width * ratio), int(height * ratio) + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc + + if not resize and (width > 500 or height > 500): + _LOGGER.warning( + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, + ) + + dither = ( + Image.Dither.NONE + if config[CONF_DITHER] == "NONE" + else Image.Dither.FLOYDSTEINBERG + ) + type = config[CONF_TYPE] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) + invert_alpha = config[CONF_INVERT_ALPHA] + frame_count = 1 + if all_frames: + with contextlib.suppress(AttributeError): + frame_count = image.n_frames + if frame_count <= 1: + _LOGGER.warning("Image file %s has no animation frames", path) + + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None + for frame_index in range(frame_count): + image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") + pixels = encoder.convert(image.resize((width, height)), path).getdata() + for row in range(height): + for col in range(width): + encoder.encode(pixels[row * width + col]) + encoder.end_row() + encoder.end_image() + combined_data.extend(encoder.data) + + rhs = [HexInt(x) for x in combined_data] + prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) + image_type = get_image_type_enum(type) + trans_value = get_transparency_enum(encoder.transparency) + + return prog_arr, width, height, image_type, trans_value, frame_count + + +# The built-in static-image platform: pixels embedded at compile time from a +# local file, a downloaded web image, or a Material Design Icon. +CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings) + +FINAL_VALIDATE_SCHEMA = validate_image_final + + +async def to_code(config: ConfigType) -> None: + await new_image(config) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 5f8e5ca1321..37a9afb84db 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,38 +1,27 @@ from __future__ import annotations -import contextlib +from collections.abc import Callable from dataclasses import dataclass -import hashlib -import io import logging from pathlib import Path -import re from PIL import Image, UnidentifiedImageError -from esphome import core, external_files import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import ( - CONF_DEFAULTS, - CONF_DITHER, - CONF_FILE, - CONF_ICON, - CONF_ID, - CONF_PATH, - CONF_RAW_DATA_ID, - CONF_RESIZE, - CONF_SOURCE, - CONF_TYPE, - CONF_URL, -) -from esphome.core import CORE, HexInt +from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "image" DEPENDENCIES = ["display"] +IS_PLATFORM_COMPONENT = True + +# Name of the built-in static-image platform (local file / web / MDI sources). +PLATFORM_FILE = "file" image_ns = cg.esphome_ns.namespace("image") @@ -135,17 +124,6 @@ class ImageEncoder: """ return False - @classmethod - def get_options(cls) -> list[str]: - """ - Get the available options for this image encoder - """ - options = [*OPTIONS] - if not cls.is_endian(): - options.remove(CONF_BYTE_ORDER) - options.append(CONF_RAW_DATA_ID) - return options - def is_alpha_only(image: Image): """ @@ -338,60 +316,11 @@ TransparencyType = image_ns.enum("TransparencyType") CONF_TRANSPARENCY = "transparency" -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - -SOURCE_LOCAL = "local" -SOURCE_WEB = "web" - -SOURCE_MDI = "mdi" -SOURCE_MDIL = "mdil" -SOURCE_MEMORY = "memory" - -MDI_SOURCES = { - SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", - SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", - SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", -} - Image_ = image_ns.class_("Image") INSTANCE_TYPE = Image_ -def compute_local_image_path(value) -> Path: - url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key - - -def local_path(value): - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) - - -def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) - return str(path) - - -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value - base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" - - url = MDI_SOURCES[source] + mdi_id + ".svg" - return download_file(url, path) - - -def download_image(value): - value = value[CONF_URL] if isinstance(value, dict) else value - return download_file(value, compute_local_image_path(value)) - - def is_svg_file(file): if not file: return False @@ -399,62 +328,6 @@ def is_svg_file(file): return " 500 or height > 500): - _LOGGER.warning( - 'The image "%s" you requested is very big. Please consider' - " using the resize parameter.", - path, - ) - - dither = ( - Image.Dither.NONE - if config[CONF_DITHER] == "NONE" - else Image.Dither.FLOYDSTEINBERG - ) - type = config[CONF_TYPE] - transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) - invert_alpha = config[CONF_INVERT_ALPHA] - frame_count = 1 - if all_frames: - with contextlib.suppress(AttributeError): - frame_count = image.n_frames - if frame_count <= 1: - _LOGGER.warning("Image file %s has no animation frames", path) - - # Encode each frame with its own encoder and concatenate. This keeps every - # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] - # per frame) so animation frame stepping in image.cpp / animation.cpp stays - # correct without needing to know the total frame count. - byte_order = config.get(CONF_BYTE_ORDER) - combined_data: list[int] = [] - encoder: ImageEncoder | None = None - for frame_index in range(frame_count): - image.seek(frame_index) - encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) - if byte_order is not None: - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() - for row in range(height): - for col in range(width): - encoder.encode(pixels[row * width + col]) - encoder.end_row() - encoder.end_image() - combined_data.extend(encoder.data) - - rhs = [HexInt(x) for x in combined_data] - prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) - image_type = get_image_type_enum(type) - trans_value = get_transparency_enum(encoder.transparency) - - return prog_arr, width, height, image_type, trans_value, frame_count - - def add_metadata(id: str, width: int, height: int, image_type: str, transparency): all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) all_metadata[str(id)] = ImageMetaData( @@ -780,17 +388,10 @@ def add_metadata(id: str, width: int, height: int, image_type: str, transparency ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + # Base platform-component codegen: each entry is generated by its platform's + # own ``to_code``; here we only need the feature define to be present. cg.add_define("USE_IMAGE") - # By now the config will be a simple list. - for entry in config: - prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) - cg.new_Pvariable( - entry[CONF_ID], prog_arr, width, height, image_type, trans_value - ) - add_metadata( - entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY] - ) def get_all_image_metadata() -> dict[str, ImageMetaData]: @@ -801,3 +402,198 @@ def get_all_image_metadata() -> dict[str, ImageMetaData]: def get_image_metadata(image_id: str) -> ImageMetaData | None: """Get image metadata by ID for use by other components.""" return get_all_image_metadata().get(image_id) + + +# --------------------------------------------------------------------------- +# Legacy top-level component -> `image:` platform deprecation helpers +# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. +# +# `animation:` and `online_image:` used to be standalone top-level components and +# are now platforms of `image:`. Their deprecated top-level shims use this helper +# to (1) record each raw entry as it is validated and (2) print a single, +# pasteable migrated `image:` block once every entry has been seen. The block is +# emitted from FINAL_VALIDATE_SCHEMA, which always runs after every per-entry +# CONFIG_SCHEMA step, so all entries are captured before it fires. +# --------------------------------------------------------------------------- + + +def legacy_platform_migration_warning( + domain: str, platform: str, removal_version: str +) -> tuple[ + Callable[[ConfigType], ConfigType], + Callable[[ConfigType], ConfigType], +]: + """Build the per-entry capture and one-shot warning validators for a + deprecated top-level component that is now an ``image:`` platform. + + Returns ``(capture, finalize)``: + * ``capture`` is a ``CONFIG_SCHEMA`` validator placed *before* the real + schema so it sees the raw user entry; it records a copy of each entry. + * ``finalize`` is a ``FINAL_VALIDATE_SCHEMA`` validator that warns exactly + once with the migrated, pasteable ``image:`` block. + """ + entries_key = "legacy_entries" + shown_key = "legacy_warning_shown" + + def capture(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + data.setdefault(entries_key, []).append(dict(config)) + return config + + def finalize(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + if not data.get(shown_key): + data[shown_key] = True + + from esphome import yaml_util + + migrated = [ + {CONF_PLATFORM: platform, **entry} + for entry in data.get(entries_key, []) + ] + _LOGGER.warning( + "The top-level '%s:' configuration is deprecated and will be " + "removed in ESPHome %s. '%s' is now a platform of the 'image' " + "component. Replace your '%s:' block with:\n\n%s", + domain, + removal_version, + domain, + domain, + yaml_util.dump({DOMAIN: migrated}), + ) + return config + + return capture, finalize + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE after 2027.1.0 +# +# Before `image` became a platform component, its top-level config was either a +# bare list of image dicts, a single image dict, or a dict with `defaults:`, +# `images:` and per-type group keys. This block transparently rewrites those +# forms into the new ``platform: file`` list and prints the migrated YAML. +# It is intentionally self-contained so it can be deleted in one piece together +# with the ``LEGACY_CONFIG_MIGRATE`` assignment below. +# --------------------------------------------------------------------------- + +LEGACY_REMOVAL_VERSION = "2027.1.0" + + +def _is_new_image_format(config: object) -> bool: + """True when the config is already the new ``platform:``-tagged list.""" + return isinstance(config, list) and all( + isinstance(entry, dict) and CONF_PLATFORM in entry for entry in config + ) + + +def _is_legacy_image_format(config: object) -> bool: + """True when ``config`` matches a shape the pre-platform schema accepted. + + Only these shapes are migrated. Anything else -- a list containing a + non-dict (or already platform-tagged) entry, or a dict with no recognised + image keys -- is left untouched so the platform validation surfaces a + proper error instead of the migration silently dropping the input. + """ + if isinstance(config, list): + # A bare list of (not-yet-platform-tagged) image dicts. + return bool(config) and all( + isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + ) + if not isinstance(config, dict): + return False + # A single image dict, or the grouped `defaults:`/`images:`/type-key form. + return ( + CONF_ID in config + or CONF_FILE in config + or any( + key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() in IMAGE_TYPE + for key in config + ) + ) + + +def _flatten_legacy_image_config(config: object) -> list[dict]: + """Structurally flatten a legacy ``image:`` config into image dicts. + + No validation or file IO is performed -- the ``file`` platform schema + validates the resulting entries. Unrecognised shapes yield no entries so the + normal platform validation surfaces the error. + """ + if isinstance(config, list): + return [dict(entry) for entry in config if isinstance(entry, dict)] + if not isinstance(config, dict): + return [] + if CONF_ID in config or CONF_FILE in config: + return [dict(config)] + + defaults = config.get(CONF_DEFAULTS) or {} + result: list[dict] = [] + + def _add(entry: dict, extra: dict) -> None: + merged = {**defaults, **extra, **entry} + # The legacy `defaults:`/type-grouped forms only applied `byte_order` to + # types that support it. Replicate that so an endian default merged into + # e.g. a binary image stays valid. + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + del merged[CONF_BYTE_ORDER] + result.append(merged) + + def _add_entries(entries: object, extra: dict) -> None: + # `entries` may be a single image dict or a list of them; non-dict + # members are silently skipped, mirroring the old `ensure_list` leniency. + for entry in [entries] if isinstance(entries, dict) else entries: + if isinstance(entry, dict): + _add(entry, extra) + + _add_entries(config.get(CONF_IMAGES, []), {}) + + for key, value in config.items(): + if key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() not in IMAGE_TYPE: + continue + type_extra = {CONF_TYPE: key} + if isinstance(value, dict) and ( + transparency_keys := [k for k in value if k in TRANSPARENCY_TYPES] + ): + for trans in transparency_keys: + _add_entries(value[trans], {**type_extra, CONF_TRANSPARENCY: trans}) + elif isinstance(value, (list, dict)): + _add_entries(value, type_extra) + return result + + +def _migrate_legacy_image_config(config: object) -> list[dict] | None: + """Rewrite a legacy ``image:`` config into the ``platform: file`` list. + + Returns None for the already-migrated platform form and for any shape the + pre-platform schema never accepted, so normal platform validation can + surface a proper error instead of the migration silently discarding input. + """ + if _is_new_image_format(config) or not _is_legacy_image_format(config): + return None + migrated = [ + {CONF_PLATFORM: PLATFORM_FILE, **entry} + for entry in _flatten_legacy_image_config(config) + ] + + from esphome import yaml_util + + _LOGGER.warning( + "The 'image:' configuration format is deprecated and will be removed in " + "ESPHome %s. Images are now platforms of the 'image' component. Replace " + "your 'image:' block with:\n\n%s", + LEGACY_REMOVAL_VERSION, + yaml_util.dump({DOMAIN: migrated}), + ) + return migrated + + +LEGACY_CONFIG_MIGRATE = _migrate_legacy_image_config + +# --------------------------- end legacy migration -------------------------- diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index d47c2e8b445..552a43acade 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -1,150 +1,35 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE this whole file +# after 2027.1.0. +# +# Online images are now a platform of the `image:` component (`platform: +# online_image`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `online_image:` key working during +# the deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components import runtime_image -from esphome.components.const import CONF_REQUEST_HEADERS -from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent -from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda + +from .image import ONLINE_IMAGE_CONFIG_SCHEMA, setup_online_image AUTO_LOAD = ["image", "runtime_image"] DEPENDENCIES = ["display", "http_request"] CODEOWNERS = ["@guillempages", "@clydebarrow"] MULTI_CONF = True -CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" -CONF_UPDATE = "update" +DOMAIN = "online_image" -_LOGGER = logging.getLogger(__name__) +LEGACY_REMOVAL_VERSION = "2027.1.0" -online_image_ns = cg.esphome_ns.namespace("online_image") - -OnlineImage = online_image_ns.class_( - "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +_capture_legacy_entry, _warn_legacy_online_image = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ONLINE_IMAGE_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_online_image -ONLINE_IMAGE_SCHEMA = ( - runtime_image.runtime_image_schema(OnlineImage) - .extend( - { - # Online Image specific options - cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), - cv.Required(CONF_URL): cv.url, - cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), - cv.Optional(CONF_REQUEST_HEADERS): cv.All( - cv.Schema({cv.string: cv.templatable(cv.string)}) - ), - cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), - cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), - } - ) - .extend(cv.polling_component_schema("never")) -) - -CONFIG_SCHEMA = cv.Schema( - cv.All( - ONLINE_IMAGE_SCHEMA, - cv.require_framework_version( - # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed - # esp8266_arduino=cv.Version(2, 7, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp_idf=cv.Version(4, 0, 0), - rp2_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - ), - runtime_image.validate_runtime_image_settings, - ) -) - -SET_URL_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(OnlineImage), - cv.Required(CONF_URL): cv.templatable(cv.url), - cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), - } -) - -RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(OnlineImage), - } -) - - -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True -) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] - ), - automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), -) - - -async def to_code(config): - # Use the enhanced helper function to get all runtime image parameters - settings = await runtime_image.process_runtime_image_config(config) - add_metadata( - config[CONF_ID], - settings.width, - settings.height, - config[CONF_TYPE], - config[CONF_TRANSPARENCY], - ) - - url = config[CONF_URL] - var = cg.new_Pvariable( - config[CONF_ID], - url, - settings.width, - settings.height, - settings.format_enum, - settings.image_type_enum, - settings.transparent, - settings.placeholder or cg.nullptr, - config[CONF_BUFFER_SIZE], - settings.byte_order_big_endian, - ) - await cg.register_component(var, config) - await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) - - for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): - if isinstance(value, Lambda): - template_ = await cg.templatable(value, [], cg.std_string) - cg.add(var.add_request_header(key, template_)) - else: - cg.add(var.add_request_header(key, value)) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +to_code = setup_online_image diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py new file mode 100644 index 00000000000..cb86f93e294 --- /dev/null +++ b/esphome/components/online_image/image.py @@ -0,0 +1,152 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent +from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL +from esphome.core import Lambda +from esphome.types import ConfigType + +AUTO_LOAD = ["runtime_image"] +DEPENDENCIES = ["http_request"] +CODEOWNERS = ["@guillempages", "@clydebarrow"] + +CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" +CONF_UPDATE = "update" + +online_image_ns = cg.esphome_ns.namespace("online_image") + +OnlineImage = online_image_ns.class_( + "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +) + +# Actions +SetUrlAction = online_image_ns.class_( + "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) +) +ReleaseImageAction = online_image_ns.class_( + "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) +) + + +ONLINE_IMAGE_SCHEMA = ( + runtime_image.runtime_image_schema(OnlineImage) + .extend( + { + # Online Image specific options + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.Required(CONF_URL): cv.url, + cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), + cv.Optional(CONF_REQUEST_HEADERS): cv.All( + cv.Schema({cv.string: cv.templatable(cv.string)}) + ), + cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + } + ) + .extend(cv.polling_component_schema("never")) +) + +# Shared schema used by both the (deprecated) top-level `online_image:` key and +# the `image:` `platform: online_image` entry. +ONLINE_IMAGE_CONFIG_SCHEMA = cv.All( + ONLINE_IMAGE_SCHEMA, + cv.require_framework_version( + # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed + # esp8266_arduino=cv.Version(2, 7, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp_idf=cv.Version(4, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + ), + runtime_image.validate_runtime_image_settings, +) + + +SET_URL_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(OnlineImage), + cv.Required(CONF_URL): cv.templatable(cv.url), + cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), + } +) + +RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(OnlineImage), + } +) + + +@automation.register_action( + "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +) +@automation.register_action( + "online_image.release", + ReleaseImageAction, + RELEASE_IMAGE_SCHEMA, + synchronous=True, +) +async def online_image_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if CONF_URL in config: + template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) + cg.add(var.set_url(template_)) + if CONF_UPDATE in config: + template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) + cg.add(var.set_update(template_)) + return var + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] + ), + automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), +) + + +async def setup_online_image(config: ConfigType) -> None: + # Use the enhanced helper function to get all runtime image parameters + settings = await runtime_image.process_runtime_image_config(config) + add_metadata( + config[CONF_ID], + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + + url = config[CONF_URL] + var = cg.new_Pvariable( + config[CONF_ID], + url, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.placeholder or cg.nullptr, + config[CONF_BUFFER_SIZE], + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) + + for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): + if isinstance(value, Lambda): + template_ = await cg.templatable(value, [], cg.std_string) + cg.add(var.add_request_header(key, template_)) + else: + cg.add(var.add_request_header(key, value)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +CONFIG_SCHEMA = ONLINE_IMAGE_CONFIG_SCHEMA + +to_code = setup_online_image diff --git a/esphome/config.py b/esphome/config.py index fc8f46909f1..976faed4476 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -599,6 +599,18 @@ class LoadValidationStep(ConfigValidationStep): CORE.loaded_integrations.add(self.domain) # For platform components, normalize conf before creating MetadataValidationStep if component.is_platform_component: + # Legacy config migration: allow a platform component to rewrite a + # pre-platform-format top-level config (e.g. a bare list or legacy + # dict form) into the normalized list of `platform:` tagged entries. + # Removable deprecation shim hook; no-op for components that do not + # define LEGACY_CONFIG_MIGRATE. + if ( + (migrate := component.legacy_config_migrate) is not None + and self.conf + and not isinstance(self.conf, core.AutoLoad) + and (migrated := migrate(self.conf)) is not None + ): + result[self.domain] = self.conf = migrated if not self.conf: result[self.domain] = self.conf = [] elif not isinstance(self.conf, list): diff --git a/esphome/loader.py b/esphome/loader.py index a9287abf866..22db8b156a2 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -135,6 +135,19 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: + """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. + + Called once, before platform entries are processed, with the raw top-level + config for this domain. It may transform a pre-platform-format config (e.g. + a bare list or legacy dict form) into the normalized list of `platform:` + tagged entries and return it. Returning ``None`` means "already in the new + format, leave untouched". This is an intentionally removable deprecation + shim hook. + """ + return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/script/build_language_schema.py b/script/build_language_schema.py index bc97a0d6035..f6dcf00851c 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -390,16 +390,6 @@ def fix_mapping(): output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config -def fix_image(): - if "image" not in output: - return - from esphome.components.image import IMAGE_SCHEMA - - config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA") - config["is_list"] = True - output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config - - def fix_menu(): if "display_menu_base" not in output: return @@ -763,7 +753,6 @@ def build_schema(): fix_font() fix_globals() fix_mapping() - fix_image() add_logger_tags() shrink() fix_menu() diff --git a/tests/component_tests/animation/__init__.py b/tests/component_tests/animation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/animation/config/anim.apng b/tests/component_tests/animation/config/anim.apng new file mode 100644 index 0000000000000000000000000000000000000000..927af5eb05a94ea8b1cdab493d2bfd8feffb7eac GIT binary patch literal 12626 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsI14-?iy0t*k)fqhyqJN3 zu_)8oIUqARnSr5VPU*zm-pq~y?e@a17dx87#KasIO%?1F*dpjNL4$?Uuxb6XqDsz6 znQ}qF=!0ep6mI>{`l5d!Y=an!tKboX zTYVdCnLB5)#olv&U!|$4R-2Gyh7oa6UMEQpWhlN26m)D)jSgdQSAQ{apO?Pi4`h z)m_(bIHk{3(fq{Iy-V@x<4tNV{ii)Pr+)pPAK!aq$MT@N51Xf%U;ZP}a?Msl)aUc> zD<FnGE+hE&XXJ2!HJOsM4X<>^&bX;r(O zZlq{?DX>Hy(Q@=WqJF_BOmK6+QhypCsV0jM@i286ML2<_lHSt%=NQ=wN3n6|NjYGsjE&+&8Yr*(1*e3YXjHR!`4Bz zD*cm$Oph^KH29QxkaynU8$oM76z|z7P-fawvUvMT{t2#}8edPiEq

o;kxmZTaNd1mhcgW+Dkv z%NQ>Ey}#?$sBxZwXZp6|W~v>=78^fnZtR|wR#14F$1YduAlEIv%SD@K=&2rl73FvA z+~R}(1Ao0Od-!GZl+dsxwS}ua%_eP&(#pB6AZ^AnL-L{SCP@QRbq`jiBHM2~2P}?G zIC6e*i=8WHoLgsX-nQncUw(SsTDR!_^$X8Sr-`mnWp-Hob9?AozYnRGwKRe=OwR>7 z9lflrH^p~qXlCz;?W#GSj&AcbQ;PoB%5AXW;Y#1gr3bf}Z9nh4dX3oRVyR8DtY+SQ ze`wjBR}4v_s^3Jt-egbde63@=qj8)0#rOAfq8H|ES(5i+%60QK+XT+dcVE0LTqWMa zdunO(RnF5rf3IImzwvB!?u}Di>lWzdeCK2;77I_@pu0+M_03ax&I~LpjzU+jRK~E@ zH2Up0m&5v6(^;D1)Y&ON)=rsu+j{N;_POgW^zZt6{)gu-aq3dFs_l$JM$yU4Gm-cMOY|9Bs>uwo9&9_CWIWidDa?=Ki_O z!ovB##pt8)sRw(fH0s`s`*1og{6+X5*-1XXV@9v#6o5v-s=?us6b_wwXxY;e}kzLf2)cyYdVxD)OQtPzojCd zcFaDdxzBo&kH&1R&oZ_4Z+;*AUBc7HIz={S??PWkb%t%u+beiE&0@2*Es~pb?ewL_ z1TBWe{yY^1=Nz{4?d^8&{89Wy);gB+F-N7dL-f+aQ!HiAsd&a#xWD+^zE7NG&Eb7_ z*!>%)|Cy>6Ve)VPf~?o=ww7U$*<~vd^Sd+WOBWtx3nJ*dd@sq!r}0M*WD#@FQe=uDaLJ%&iv5_(_Z8zGIZYL`Le~%uZ(qq zlU&g{HkOjYpgA(JHeZ(g^9}g+>8jA73n~o($2P=YpYz^DF3>|2NR z)-eApaND&hF4uRG$`rdE{;j&-4y(N=w9VJ%-+06$E+q2pv=0+fuOwP^9uQ$#v|TY{ zLWuOIi0QWlUfo(`UfOV*XQm@3b9g|S^rPEBrWxX0+Y~>%sn}ZD^8NU2fh$@Y=CWK8 zGqY{awGk5dBEljN&G+kqxchZ6TfWk(G8d&wn7-}Pua5rtg!j~3&wqO-d|EbN@5_QK zVeLg7bK(?5>QJ6tYM%oun#&S&BM1I5NK4%vEf zCT{6s6xvns`-bp^hSg@w_f>iP-<)5(M6Yn_Dy6-(JeE|IHw9=3%p*UzNQ5Tys_< znG^OhKO62J{*#d{-!W6T!;sY|-a+rFc(;))i@&|ZpFa6GLF@MTmdLaHzppcT$j*B$ z#l}*c&2{4PUW*B3h6+MnwT(;e9$hUw!S;{JbL}#b6W>4Pom62wxoc}wxE8XFd!pth%U7z}XHU%vUyr1J6us!_lq0nRTD^_Xoo|t29gYcTKu%q%^sE_0Oe$TG@icpE7>x%I+8M znDsU`$DypU&V~7y?nPZaqubG(6Fhgw3$agRO^b2Z7H0Z4E?(hg^Ijc^#ZD)8Ihwv+ zV)G;+wBY!Ly6pw8`Jd#L_k6z+{-MadR^i$qrjNUWOj(<189rqx3HV4~D>?&({XEI50Esj!PH*R=Pdf?OEHLg4= z+^6OlGFl$V^^!RuUm;_}I!V}V=F91UjVq?x^-C#u23$&bQ}%oE)XIF$=d7RB=r*o6 zn%?K7P`TWx?cj+fBi10p34sYdjXlEiZr@L1yjVY(`S8Ej9PM{a6k-`!xY1Qjm3)t)i9 z|GoCDV*5jfWz2^SD}#UB{oeG~xaj9f{+gXBiMMjzi+FT7uC_R@7w^S1v3v8`vp-ml z@bOvraXisIzEXd}UsHx4Q4ZM?cJb>PmfNs!y_cxESpDjIK}*N%GhFK8SCbt)9m;l} z{LXge&>`;J3#%I6D6eOjP&YNzy+3r}8Vsa}DipK5f1q zd_3r}DDz9VhBw8NY*jK>emuG?(cz0*?Tq%8yZ5xNSvfMdOnz`OcPoclgNklbCu`fn z71N(Y=3F{o=(DR!FKcGCp^we3SqiMoPcMtN{A)OGdpb}tVeyY!@0A%(ZueQEev8ZD zK+wvhnGyGUOIW-0Pg}j(w83?H?_1s_-Gywsw!QFWY|xW!i+OsB|8&piJ>F02dc#$x zo<5S*{jqq;_K6K^o~Bn{z4g9BwrT3xjS(NJZBB}<`|@Vz!$pTqEZBA3W`5JbOCir} zgeL6Su*hJ}iB;FyE9XafNSamcnZ9Sjrl{@Pm$aK4JvmA2@o`y;iY7T#hkh}G``*3h z3&Tx(i(Nl8KVIo>TEO?Mr()*SpvNoTZQ%Ls&GMWxuIGQBfQL3qQ)x9fli}}+f@}IB z_RBDCIFj_|$)t#aI(uowd4^9_xx6GrjpUvy@4HYx>5Js89alnjdF`+Fvo7j8Vf1*_ zpLx=eJ^xj+oD;Nf@N9FwvaK{N`iNPG+$HVk)J8o&DL3~A2KLo9H)p7~<^?#sdL;YM zq&?mJ-*V>8`NDH2tE_0R-5d5I{CaVmj#X1L1KYF@0xf?c+?x+SH4(cwT}qxMDJ3*= zk3{|Tt}Tik)>reL=P*jl?EVNY|WA84p%NtUY9w)ai zNe=n-yZwpp{9l33J$Ef%x9a*2-c=_oQl{)&^Vsr}VVt`|z=mwgCe?O4PPaojLurA{$)$s>4g0@Myq|P-UEXY^8r>Yu+4JsYa7jP^ zB+y+IEB}yT*NWGAhPMxS3nYl=o;)ja=jpk;Xck|W#ijPKhDrt-IX)^^CrAHKy*RO% zvHZ!mylKDwz7x6YoViTXU*Jh&ven+kWJB2^`>FhgESO_&|K7QW0n*aG-uZ6b7e3KB z5{>f(8E#3nOk`Lh$a}xm?s$+pN0!4*H=d$fN^@T1$}E$PTfC%w)4emn=TpD#jku9B zr&##>YRyF5RW@GSN7tU4S@N!eQUjiFr1^q%;tB+^YB_HQ&zB`?_NWb}3xrrF3-Ns65-5|>Yu}K+aZyPb0|Vn6&{#l6Rgc5&hUV`#3?DdNaLLfEk3aif zecJTMsMWUuzkdIEzJGVQ>!-!?>rd&N>W%np!W4h{sq6JJ)#D4?XEV;8tDkdYQQ85K zz(uxs7tY#*PSf1ua_q+JM2!ViZ0vj3C0Z7^Ii0SY>f0jBs-enLw`9$S8A^vlL&IDz zzj|&O8F@V3?QVom`-=A`n0IXxFJ#^~C6`@V?s#td*}2P?O=R|%+}N<-aH;I7^}?El zCSN$t^;JCJaO_=iyyG>O;v5a72&D|AM_+UwA54{9`l)PNVoyomjw^OoG#Uz|Zho9H zjis0osLb+M2!8dkK<>y$X+uhS`uf(zYvH`2amwkPrOQe-GuJV;gOqdT{YMUM*}t4`xrU}igh?1Aa+pL|_=XUV9TD>@Jb>Meg z(p&9>^lAyWYy7JGnZ5>_eDse=Kl!=rUBmTui!=6Lr1u@n{-f;voy)0b;J4u60mb(S-vFzLVN5HV_^FRkLe=wix zC5JHMd8@xIV_;GDp5fHN^w8X6@53chx8^>6Dqm4K@2lec>c;hv0g1AQ{zVFgUVGVe z{`PF{-vw`Mr02LkpL57ERo}3>r(Sm6rOPWePiJF3xjS{{R|UQdo|bfmHv3ld#_94F zl?>kn=l?f+Eo_&6QuNo9lARt}GV@hi%Z}#qMVZY0GxNIsrhg}X+2+sRbm8B!wz`)h zXG|+RN-s1OsH)E{Kk)Q{WP_qyp=R~N509ru&fmCh)4x-{?aP(cbZ_`SOE*1ms@dsJ zt8PBg=Q5(BvcGBVUE6XOaJuBh3$a7@hY7K!&_oh@F-}AJY->C87L&oqQ?)#od z*B(8+{^t+gxc}QGd1YvKBHr@%;|-v|A*yjCB9ZYd$rc#kt}0}wZoJ~++Vw!J?cNY&3*Qc+)Hj- z%(FJj?9ShoC|!Bu)uu|$oDLyXc-8o&(&N0_q8%@Gl;uveR*9@l)9c*+V4od_ z?UQ2p&%cbaU8isQd*bBAnV0_O{5)}M$DwT%K9}-t)@A4`=X{o3xi(>Qzv=%j-Tu|N zsYmX!b?|)rnIhSGF4|t^uH_el30wAE3{c7Ek>lh4+VXw<$6vvA+m7AN-m^FB__Vt@ zoVihonVYwKUAb-cyol;V;cp`K-NoA+)o5yVyH~YHazCB;6+rMm@)!@P7^3UNt z=fhXqcCFbX+OY9l72C&;>3;-X3(ir`OKbUNQT=hrS?M*;mz@6UbG|ZXn`7MGmsiug zvkp&WoY;7NQt8JDXC|HzSU2sg@sGwkpPha+=(QVpcO`mM*DqakHakuD#)^nWY0=eW$9_ zE06zeYdv>TYwD+tmSv^E_tI>|EsxlrNYQzgFCie^-NVAMcey;*Q)}KT9*LDn2`z=k zl&c^7Si4f~%Y_Azf%kTQa@D(OaQcfTBG5F`WG&Nx8H*9z3tsy z-?5QPS$o&M%}a!)SZx0hv^_k>gioRN@r3oUl`@;&c=1Pk>WP$X4_WXe_gEog()@@I zxvy8<(qr^=5SqZE0;bk!GQON4G@%bawNgSkjLFDV!MFaPx5F}qlpFIyg(q+_WqvMy z$Fd~ZL5))tY#2+5OM=h@J%*E70cz0)kZm$pDfVREyu%?hy~CL>jiNM{Xi zhht03;)29a2~GIOn8~ZkI8$lDn*HqCCN-1@cTFY=dZz#i|Xe-#s0o7z)2S z1o6#W)Xu}SIaT7zB9le_4%?Vg1$uJrf|)k)YAF9L5poD-GLq6z-u1j|t3Kn+K1X+s z$cB_fEK35f6vex)owuItL{@&0!=HU~IZ|Tgg)0(b@&MH zUfsy^%Idg-+LgC=?>e*{IACe2Tsm`=CgV&Imizx>a~3%m&2QlOy=4EYw>k|MENoM% zJ#A~kUf4}ITiq6a&db3|IOQ7SPxfARj39Z>q@WS>}GiuYcJt&E7PzY;V>)%a$iU-Yd*_ZM((N z$GERpKCp7ujqJs2O<5^s->dB=8g*?8QGdMs<@Qp!+g_VqFJWUaTzuT=T*vj#or`nJ zvNtlW+kEoJ(gnUVJSuk^M=$;ta?*L0+GGBQS3XrKO?y?a9IxD?(++u`cnvSws6DRF;r}t`)6V=W{^ll*V1bt?1*v)EFtS++}LdzO99 z(2@(cNj4Vvuf3?{>6EVamLk!`pC?SXa@P6L$seZe6Vxn3idJ%lA5N-Vthj7mGMh%r z+OGnGGBrYi;881N3F;{RP^Fn+oFU^aay%ysL7r$pIbEST^TQqA^WgPeY?cXMZ@13^5tuR((+MbNW*Iw&2bbij=wTwsP{ipsr z#gon~5#QE%Xi~}QH8uaguls6yY<=99Jxk7a9jOf$-tnYIzGmeHmB>U-&legmYK*jW zIv>qlYooI2i{{q@6K|aVvgE?LD`l~pxTg8s{~!A}C9X|G`Eq`)wDK>rrQz#;M|qyy znRwmL`Rb+B%N&=62VRg|8oqSS_9<~^+os2r8VcA&Rz)qe_?Wr=Zv2&!y(w1&_pPzm z*v<8Im;MXaxH&tfZerWIw~lL9T5d|>9L40`3u!l=Y;qHB{HU(|X3xtVmzWLMD6>ec#se3)%o=Obgp3J+uPU=wc)v53Qx#TLP zW{GdPVwsSlzE|yYveddyO5dG}Ic$!;TB)4F>8bL4`*s~xx|*XJ>oA6s(0OeXwsA&%N6Wd zK7H0Z+2Sal!*tu@Wihkj(hGvQEN*Jsj(Xb*bkw{wRroN8!SxeoRqPLydXMhS-ZD0e z4y`EOu5Ep)tn%!?l*BSYju*b19K69Fcs6S8+TG!@Pcem6!cb%mPgl{#itfwv7^@O^ zJlM=6a@D?i=4_776@4S-;9+&*l$kzb;W~K_M%TTn9(T?tbXI+{RnrXL;BBz)#_IHM z#kZ_&2 z?auA$%$H?r|KIw(=h>N=*Jp;$`YTIX%Ty;xhbV33+vTNlich8_=UVnI!6|MAN2Cgj+9u4HDDV9~ zr1Om4uZxzV=XF-NY*U)Fa;yHo_cM!mH?wR@J!yHY{&Ka#h0rYRgEu)0Hdf5EDixd? zvtSEHi#^XQo@O1ziMM(tGdtw1_j+cx=+VypRVj7zy~NfFXx7+76op@U&c|}%-^3{) z2k+nW&zN|<^HAN?6Y+eqs+;659C*f{HZ4YfF;6r%Z{od$+E<*4k|Y~8%BuUMeP0#w z&gf6Zsvn+HH`%QXTI#Gaf9e1A!7D!{y8qZJXjiwgGjsO(vnL;Iz2Ld2qSs4&J=>0u zcSQ}I>EBjJycDuJDlqN(za=yI6<;URA3Q7)&6e=Qjw9Pc)UH+DXS)6ROOxYOc6z2R z>sxVWS9D0(udx0UwY~=tX|GoJ_w{T^QC}*X8DG6Qc>BYMs#k88PV+8Vtj{6yIx8Yf z>9=B&(ljP{pD?ESOEMg_8lHSMmd%M;Gh?SL?DRWh%V=*_JNwj{|GQ4*{aAJ?*J7EG z@$t~IXL`{_((jH`&zza8wfo=hhnpT+FIFzuyKL{JXSvHrJ|p4Rr4>{`2jwrg$w z$~g7@p9QD#|8ATnthN2A%E@M>lA0;1(qH~fW54Kk`Pm(_yU*5szj$BX(VW43?lj%D zg-g1_C+6?$S#ahsufy>-cV;slym2g0bWQSlkR>;aIM}{({JVJQ+Wb|2zQwON^zHc6 zt)I8A+VQwt#edQtwxAmySx#!ote<@IxLf#-Un_H$&ApVCX)dpwr#v^OR>CSNcIoo` zaLr4yZTe-uHcX2AG0CjKhg+aaG=h-Jmu|Iw@9y#6yH6sEwAWyJzMNjbGgY6yGuR74q5dsEz7i@ zl+WrBs9sylk!v|CiT&KOs0|i)&G6U&i-dA zvs$PjQWMu8vqy60|M zC#d>WCo#@A}P#PRwr=Y4Y;8kI9{=tU6 z#hMlcQqDJ1mWHnp;$YR()%;!0uxf9#I?sC9ho6}%4(znDj2BUw>whb9U2Ms%cw2od zMeRh{rzd`HJ|`gLe}B#7hC+F29~y)~^!9g*=CK zCwp_*vLBy*Zn3-NCawCN>8660r7tRP72UP=;-@*=w-%Qv=SjT#_9;92BU1^}{6hzM ze+pik$*?nYsdsznlB?@lTbL?^9Bwpj?)1}(UDF(xuE{)Qo#GD#bEZRw=BLh$m}(x9 zc)t6>RB4XaLJo^IOt5K~Gl8S~ICqD~kG1cpuuN|g4WZXG?=SrLP#9pQU3uZ{$wJg@`S$WW)qNluVMd`iH|V zNa*ri)?fXt@{Z7x;4a(G^3R|AGMVxzPI8uSwdw>*T}8v60xHL&{aoHSzfhT>v1Ib6 zDU6#!{C-)8J-;wbuSq}6>$}v>1K*D*>QB71>bZ^4!(aC^mvn^2Htx9p*yBunpWCCK zasH`iO;x0ypZk~Rs&xKm_{8oHKCH|7E2L6iWrzn(ljk^Ja!%&5gP$kcJFey@ylvhQEFrrymgd9u7#)M`3k zwXXG>T9ZW^t}~pJKj1k-FU{(2CC8+tspng7ChvT?b89)%(yKf6U;J)u8*fs1qAOQ3 zNmA|oqC3k!<(nG*7wT2o?=Cm#ZjpXk*Kht7r)c&Qt4!aF@aDb1iB;#g!`K!W==EukwDO^*1eZ`%pLq~*HkN=}{ zhuNP}=AAeE6+X){&y2CTZ?3TSr0FciNNJV{&%}b({bd(F_+2H@GBo|waOpjs@?Y$ztqRob1l3CJ7;oRSK z@5L1ygVk$Ui$u0>>S@flc=`Qdc9;9pLrb13`ZkoTYFS|I%eJj2yJ1IO>|5yxTaG&9 zZESp!p6V-m-a#ioZqpCHyIqOO3c1cJc)CmHl>L?8=Mc*O#7=yI^QMgsQ^YQ5PH6n{ zWS&R4Lr-hM?%ov*B0nA;`#C*V;aQ$*o!uec6p3qB7SE5$?|x!~2&qU`+X1+f#a6)^lPV^&yh&1PiDc4=;F%Jw_=4>;Z9WeHkWlBYOV zW9@c_m|Vlo#L}~Ve6|iBSf(<5YN%^k)$e!riQ-+RkI_E*4K9zo?}#n3PEO{2X(&)H zC&ad7>TbWPg*)asb22pj=J@kk@wWVW=1E#LYpNCINIWw)@!(l~m+_ zy|~B0=umNOk>&(#29=q&MBZNMOkL)u#B|BKQ>>4{u(7B5rj$cPv6Q{Qb?5m!E@gUe z#V0V{&$(Nz!KCuRaNjM-zY!(H3br%rzB8<9>y*iNjQ_}+h>cp4SL7*J`(0)1 zOtEG0yPI?VirR#GITKAhyjO0Yu#cHD{!nQ{g;;jm1*fKlDPrfYwBCLAz31etDvJr> z#~pTU6!`x;S)qB`q`roj4UHefLO1R0FisIIw-tVI<+#JG4O8-JI^;RZH0FMd>?pk} z;o;x7BkxT@_rZ?_3d!7=d`Iv6xclqueYO*;n2*>r8M0ln&g414dgRQPu=BSa)GUJU zf8M0Ev+;+T_;H6tV!TIqqWfBPRvY>65E95vmv2a^bx4`~aPz7F(a)vV*sg^Xe!RpG zw1zG6)TUW6F`pR@=K9;P+;aH0Voh%U>O0SVbDk*VeWFkzB_Hi^Wm8PL;#Y?hz3#>* z$DPGaXX*yVHe1GLJDjq<Z= zwVK?kXq|8&UjC~&y=nKVY!?BAqRpmi zF_o{5upZg1{j!m-VaDH0U+%qMAuGDUS7#N=i`=Bw+kIj$?=Ih_{5I$6t#>=4f1Yi4 ztmm*Jd}r0UR7vq%K?m(krFzdc%{%^=-EYcMhR*cpJKN3Qu|K-!G1L6@Fabodh3yj z^w(wEr25<6{jJ-6@AuukSARr);rYMq_MXSadRtmwv8L{SS^XsGaq_pja%UfYEO_*0 z)7s|?D#WiY{XDO@a+`Mf-8~Pc6}@fZP1yA8b&Fc`^R>sy_b&K!d;WpE2R~<8fAnBy zei*1eZ=KtIN&E0=56_-X34E2*-1{SR#i6rJVg5&#=PY+%h|m6^_HRz3`74coMWU*) zyi5~s{MoM&Jbmx|;%K>5>-#R;414UoA|bP(Ak#vB!u8T4f%%H>PI2&7=gv1f_uVP7 zw2^cDvI`Q&SVPTCiYxn~_O^0Nn9L`TtC-8sY3X3`#?&t5m09eCqc?lzXX`CAzw+w4 zqq*^dxBXxDepa2Aczo+j`}h9x&%$4QpAmnV@6JY{HpR>8s|#K&Tfkj&;@RYRXMap= zzv18|%M;0ya_PQ`TQHl?J|V<;LmK!|xw5^buj}5V5Jgou~b8 zQ)*e;Sueng4g^dW{>7B2kYQCFEXT@F;25imEfMW$O73FU>V)e=mr=x#!TK z?4Tn{KDDjRxwksW^|SuY6b`R@nFqIiFtLnXenPu6Xil}~ro8??zINxko^RX4?0(Zm zQA6qS)ylnpr_Qmis|Xf67M2@#R(Y2RPf&UJL|f!PF;U`uIB4!J2ib)zONFq zWNVQzcMmRjQ1L!8f{kluPBqK5gWbVzns;tc5r642F=<_Uw)nIh#*UR=PNq67ExD&C za_j7x|Eu`w-y5V;e!G4zX!D;dx}{;;c-k5l9lacXkYn-|J7vZEJbkbAYQmGfywH4OI2))#LOb`rkxt| zZyXP3`Kjpn&x1Wf_;pL0rp|4no|XEmj~q$d*!L$jC}dLi-QvPWZdVsQ{~w`b}{v&dAuEdKSe*`JeVozU4`DX%m39vd8|H-_&1xjaO54ny2%==Um76lx8t; ze3OiEo~zTWti5h=-=ZU&-Z9KCcd04_J=xb$$fus*z3>FXPEPBpu6+VR!j(MfPk#J7 zt~$|1Vg1j)6DKg|*3V1NiF{M`xy0k3%zgD;Cu<|xG|pLCI3&pytvJ2)RqksKQ}!nQ zqmo{H@lQ7L1&ZrOF`m(R)u?X#`OyLiAO!o4os%EV)8BV+&pGdTi6!nz zcFIdjWwKKg;}qU{$0iw1vogOh&8}I*ChW(9hTiHw5+@?0n19-zIwEpO-qZRBXfoE* L)z4*}Q$iB}JU+F8 literal 0 HcmV?d00001 diff --git a/tests/component_tests/animation/config/anim.gif b/tests/component_tests/animation/config/anim.gif new file mode 100644 index 0000000000000000000000000000000000000000..9932e774483eb3516bec26187591b997e6d41859 GIT binary patch literal 9735 zcmZ?wbhEHbOkqf2_|5 z$!u&{Y;0K^?Ah#`rR=QLZ0r>r?Dgy%bsQYc?CgE)>`ff(ogD0~>>Mo|9G#pTv)S3F zaj?(kV4us*Ih~VZ4hP3V4z|^t>`OVhmT+(`;^bJ(!MTouV?8J5I!?}Q9PHb;*fw)= zZs+9K#>KIXi)$+v*G^8(9bBBdxVZLkaqZ&bJj}&$gq!mSH|Gg%&ePl+r?@yzb90^J z<~+;IeU6L!BoEhlZl2TJTo<{yE^%>P<>tJ^!+nj1>moPLC2sD!++6p$x$g0B-{t1H z%gyzWoAVJj=Rm)%kzns z=MN9}4<4SMyu818d4KWq{N?5O&&%_NkLN!h&p$ri|9rgv`Fa2H^8MlC`^C!xih@x* zKp~*`pWDwhB-q(8z|~04fSHkjfkE*n3j>(`&+Y5(ZQtmB7#J+RGB7Yt zK!~Z#XJFuOVPM#HI3hAEN&##rL(+5xhVAVP3=%gB5{nYSV$2K-3`}Wh3=E%^GcfQ* zGB5~VU|`^iPR=OGh08N6zRti9Qq92NbC!W&<}3yVwKN6>{ul-wkP1+_&A{=WfssST zW5a@j%^bp7F()=GJlrmz>@~+@n{q4@7#ioZaB;|TZCP<~vBzYs*k8pOfeUP-n^JxRI-Fm?wn^^B zsjaK8uTMDKCF?yc`x=uyyGLxq)pSN?b^(`1KNbfQ4l^;SY>QG@*K|TyBjUmWMdoa7F@qEH ze0OhqdwWOm{kF4FhqJgvHC)0z%yf8h#Ew_N!uH@HR(>Uy88hVe?)v)r#^&t%=l1^k z^8TWJ#Qzx&8g?);WeID^1z0sUKa=xW(s3}Ut@gL!r`Pw-@8AFbKLb<6{aV8%2~8Z5 z6Q&t3=`kMYS0z(=wmU%viSa*{qZ<%QT5#nMP+WMUL$Y3PY<(o-!_vRB+<4 zHV}L!(<1ch*}@LBRWBCxm{nygEbcjZgwJqG#689eI}L}-<(VIz^Tlss%v_k{6s5It zsa@8qm5bJCy;|9J=)YCg!UDTYCbsE|rl&i06~1a^B=vsCj422}mo* zY~C$&MSDJ9iohF@!&BLF1l+ zgPWgay;{B8x^Cv1MMqa@z24s;$F06sEn~v#o~mE3()Tx?d-dXoNVv_0g zcEDhL^*7B!eNA1n+EtIqob^L#n&tY5RvK|qN&1+O3QHxqVHR$$Lw0Vm6OoTe8Ob`ySH+ z%h+OHUUz#P`8hL{z3SVo3iVrGZwF=nw=%!!nLhjb1Ln;S%dWV#_rAMPn!MNKVfJ=j z(>qYEV-Re;_N5T#zT85$d`#v_IfFLL92WJ+z+r2cr(A3K`f9bmr%L|P9l}s!P zS9z4~V4k+2^vCYmT+SL!;{WoRG<+WN$XD$BT4>OAYPRC_|1KFe-*WXkoOC1vl`|}5 zrk$zTm3{eCg@oO2$&aO0DN*OoY_S)+bgrHAjly@W6{ZGzSNg>EO!>9u$6@7Xc?G## zlO>l&9FW^&*pu>^f%W1vbIo__`f8F7$o^k`Ag8tKXq02 z#3~zST5#+unlyikD#ywfUY=$fC*=D);#z*)Np04J&g3VHjkvF$iI@94d8N-H-MO63 z=3JMXK53_^H*y}y$r7IMsOBN(XLDcGZJ(y)@+>g9z0)__Zt|r2SMDnv=KSw(Q#HBA zIOfSo9#htMsn7FnoO%8sL(^h=>4J`rDswHrrXMrV5$ej8d2X7#GT^TxmruM+hWS&= z^O>I}&rbe)SfSzIA@=Qc;M zH%~fbwajncHty(;U8_P$+14$cmc86??=;2br$Y12a(jLKED`p5$<=kUu7>%2d|Fid?mVMS_8s_uQw&3kt(XHt4FX}ZwKWyh?( zZH%{?Jo|via?7hH8M%d4-_O~?z!j10?*8K9B(7OYF3tM3#qF11>g*J4&BSS5vt19g z2vsZc{$IGlWyjgId5$GYd@DOxrZoxX2s+JT-jU8EIl+&Mtwdgo<;Bhmz3b{{SU3NX z>0sv43g={Wka}uxoJo?Sv1FMsld!{b>+6h+RoA;^zec6AJZ;UdI2Jrl;$@aePb6Pf z$mGU<|I(Ou9puen^LfB@c$pcS+J+pqH?yz)Xk$9GQoq?`m&JAyHlxXn2hz;WJmkwy zW1Kg0L-Uo*a%)W3^cy@2yPh9P5?wvxXtO3ymD6X-lBp8f1)AM`SnLxq9U{3D5ZAaw=Cv#y_cX14n$$Jv6u&2t{h2;gY0{RcJInBRgbOa=i4P{W6jfeA)~5rNR)*QEXvLRYBn)5Lc~C91_n^0hmnDS$pM0)>UqKHSr`}?D!}?h;Cx0fUlPuj z1M?NZd|w6z21ZbWh@ZicA%!8IAqm`A31G-z$Y&^F$Y&@9vkDj(7znF?x)QZ%st9kJ zg00m6TMO!HFxh_qvl&L)tcbSkXqz>X=55w?P-ljRfk~8+QHGIGfss*%kqHp@o65 zg@K`!fuV(wp^br|oq@5PfuW6&p`DSjg^8h+iK&&Dv7L#bjhUgHnW2e=v4w@9gMp!w zfuVzuv6GRpi-DnoiJ_B;v6GppiwRmHj8e1=0m6N@jf;=>^D}oq`)t9hVon+zaGAuE z1nINQwkUpe#M5Yr$4urXAqg*7pY2ph#)Soj?pA*^9WokMM{Lfz3+c0Mw~|$Gcy(xg z7`KE$!Gg87w--DRPjQk&sJP%TcrHRjZVpq~EMK zyzkVStt;%LUv0Zow)*w<2Sr+Xt5arG=Iz?Jt~z`5=|j@**5A^4y>{>QN7n21z5ld( z<(`XtCTqK1aNSWm_(Up4?+}-LSMH`S@1!@*=V9jgc(gVxrtpBw=9h)Lcjn0$O&5Ot z+mK_~yq-<_-^jhvJAHN69IZ2-C#{h^%X!=8@d=~pcQ&1OW71u-TTQt3^D0aB*!7oO zrOxVJa(32TeC6r6n5|b0lHP8(W?Fo9(RC)-JzH<+PU3C2nYj4vYMz__eivTK>+dZ- zm}*~Bb~pd}f35B31U{YlzNXxKj`PZTxBBum|NYfp zAIDoI&MHm+x$^#_^tl?dGSyCQofUtnzwWo)CE@=IzNy9k)GuN`%s+u^g$dg!PL`5i z^Zy2^I5-HNZER|CU=g&?(em}E&zmeinJ>d;o9g;9r9%@tPna~YuYDIS+qIA$B zhp_hKTKc4_IiGNn`eZm&QY9`ySo-nW+`i^O?&x<;C%vL3P0HTM6)*Ku{J@`y8i|}s zHQYVD7EM^%&nxMfFz?couw9>~uB++tNqH>n|L)1O9cPy6rC*xdvE|A113b$NyqP8? zNqwGi!e^P$^pj`OtUk}Y(Bk1RIzr08ATl~aI=Dwj+rd37ZblX{MrKJSb~Pq8EhY{f zW)4$kP8$|(R~8;07TzFMzDQR76gK`WR)J(T!E6qpTsGliHj!#}(Pj?O7Iv{tPO%OS z$$oap=^PRh*`;Q4NYCSxTEr={hD&ZMxBPA%g}vPJ2l(XAaw{I?QaZ~6Lg#pu&+#ao zRn#d`#fs*c~tN7sy^gV zeZ;Hwm`n8ux7t%~wdY*w&v{gz@TfoIQGLv#{)%7yHILdGUiG)U>hE~f-|%X@=Fxb| zr}>6g^F4^I{)tchGmrWg9<{H$>fiX(Kl5sQ;e41Z)wZ8CZ zeB;&p#;f_AU*j8}<~Kf#@BA9yc{RRpYyRZc{LQ2BgIDt>zvd4FW+t%FNoN;+o z?CEK_O)RNLl~fHDq%yQIvIyuJm^C)BHZXI``Rv&6@NkDPcNoL>16Oy+i`J=RF*r7{ z^2j+f2qb<^W1r{T4QXvIQFo~L(7>3?(!bs7TJ(WWP3*!&;R_NLZfE5>(9A9Gw`a%4 z$H&V*zmv5oZ)Po6f68yh^aG8jcW7sv*s!qq`+Hfl4p?jRa{qHXxjp3{rL=r182&Qc zJ;A>E`aau(PUZaaEecE=EMh+nHY&)hc+kXS_ToXafL6qV7WS$QjjW$_8KhGIsWLe%C^B_gcvR=pseW#f&72A)3l7$B{iz7Ab5dHsz#^RTfss@H zLxPJ~McT#|K{uU;&*wL=X}ws`BK9kNe%-gu32bDM?VJ^c81v~h;>UGRc=~W z?!}TUarA|ubX!38bfd-M_H64+%>wInRFZd>SSZQWt?nyJxg)V~$8qIvhMx0&g^4on z+~&CJLI<05%5s~ zl})P3dG=GTZ2TP?58oG+{(8iH+Q+J*prxGkJNCuAn$%nMN>#?1b6bL6(TqEv6E3Um zuyEJ=+VOjh>CgK|vJ{ zwxrm(@BDx0duHyu313un-)FA+x>k7l%A)7im1ZvWlRk$%dn{rltaY~RU2y9op9l8O zj~x%OGoIofo#CjzN=xToF++6Aie*77O)`WgEU~y9v*t?cC5v-KIl{4vt!Ho3f0_AZ zq3qRXT8CEzR8+l5Pnoq+pWD)V;jSrD4X(-sIWD-8%_h?Mf_bGVcSS&mVfLy})))GT zGp;h;+BoC*m+0FKVqtx!F82GsRp5$Vxw-1+m4)6@RU`LnoXQisyt@9+;n0;E zTo-jW%k_0t$TqQS%c_^A#Q*(uZoQS*iu|SWpVguR+S<5ETaM~*UX%zw_snymyKHdA z=P17ftF2p>on_j$#9;5t|5^uDCA%tR3QI=^p53;sKlPc2>h0T;+PbzGN;qC~k&dh{ z+`OaWlX%g`uG_YCddxzq8YQ;3BVKh)EuHTzakC}VvCU{=rG53mBL8VYhdRqEpFGvu z^rKSeKG*BS=c}}ivVGV47-W>fz!(0=iFG4ij6%Y^!#Ap#&pfdIw7SpDLVSzBoqJ3% zYSI5(bDO2x)+aV3)f;`&%q~k_neqG13qSL024UG< z3wjddvb^H|Et*kv`}e(uh2IlJKFng4IB@sJLccvvZKrLM`163ne@En>zxn0+cgH$i zz<+GcW`7A`@DK26*G=c&rejx*_g WpJ(0v^UUJ>&U5AKJ~uEhSOWldK6Y9F literal 0 HcmV?d00001 diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml new file mode 100644 index 00000000000..380434dcc33 --- /dev/null +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -0,0 +1,30 @@ +# New `image:` `platform: animation` form. Exercises animation/image.py through +# the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +image: + - platform: animation + id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + - platform: animation + id: test_animation_no_loop + file: anim.gif + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml new file mode 100644 index 00000000000..9d8fd15276d --- /dev/null +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `animation:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +animation: + - id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/test_init.py b/tests/component_tests/animation/test_init.py new file mode 100644 index 00000000000..1b5dd0d54cf --- /dev/null +++ b/tests/component_tests/animation/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the animation image platform and the legacy `animation:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.animation import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_animation, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/animation/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_animation_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_animation(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_animation(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: animation" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_animation_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `animation:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("animation_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "animation" in caplog.text + assert "deprecated" in caplog.text + + # setup_animation ran: Animation object constructed and loop configured. + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + + +def test_animation_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: animation` form generates codegen through the + real platform loader (animation/image.py) without any deprecation warning.""" + main_cpp = generate_main(component_config_path("animation_platform_test.yaml")) + + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + # The loop-less entry constructs the object but never configures a loop. + assert "new(test_animation_no_loop) animation::Animation(" in main_cpp + assert "test_animation_no_loop->set_loop(" not in main_cpp diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f7f60a1f4d5..78462463b14 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -11,28 +12,36 @@ from PIL import Image as PILImage import pytest from esphome import config_validation as cv +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.file import image as file_image +from esphome.components.file.image import validate_image_final, write_image from esphome.components.image import ( CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, - CONFIG_SCHEMA, + PLATFORM_FILE, + _flatten_legacy_image_config, + _is_legacy_image_format, + _is_new_image_format, + _migrate_legacy_image_config, get_all_image_metadata, get_image_metadata, - write_image, ) -from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ID, + CONF_PLATFORM, + CONF_RAW_DATA_ID, + CONF_TYPE, +) from esphome.core import CORE @pytest.mark.parametrize( ("config", "error_match"), [ - pytest.param( - "a string", - "Badly formed image configuration, expected a list or a dictionary", - id="invalid_string_config", - ), pytest.param( {"id": "image_id", "type": "rgb565"}, r"required key not provided @ data\['file'\]", @@ -43,6 +52,11 @@ from esphome.core import CORE r"required key not provided @ data\['id'\]", id="missing_id", ), + pytest.param( + {"id": "image_id", "file": "image.png"}, + r"required key not provided @ data\['type'\]", + id="missing_type", + ), pytest.param( {"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"}, "Could not parse mdi icon name", @@ -84,155 +98,301 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), - pytest.param( - {"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]}, - "Type is required either in the image config or in the defaults", - id="missing_type_in_defaults", - ), ], ) -def test_image_configuration_errors( +def test_file_platform_configuration_errors( config: Any, error_match: str, ) -> None: - """Test detection of invalid configuration.""" + """Invalid single-entry ``platform: file`` configs are rejected.""" with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + file_image.CONFIG_SCHEMA(config) + + +def test_file_platform_configuration_success() -> None: + """A fully-specified ``platform: file`` entry validates and keeps its keys.""" + result = file_image.CONFIG_SCHEMA( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ) + for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): + assert key in result, f"Missing key {key} in validated image configuration" + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE these tests after 2027.1.0 together +# with the migration shim in esphome/components/image/__init__.py. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], True, id="new_platform_list" + ), + pytest.param([], True, id="empty_list"), + pytest.param([{"id": "a", "file": "x.png"}], False, id="legacy_bare_list"), + pytest.param([{CONF_PLATFORM: "file"}, {"id": "a"}], False, id="mixed_list"), + pytest.param( + [{CONF_PLATFORM: "file"}, "not-a-dict"], False, id="non_dict_entry" + ), + pytest.param({"defaults": {}}, False, id="legacy_dict"), + ], +) +def test_is_new_image_format(config: object, expected: bool) -> None: + assert _is_new_image_format(config) is expected + + +def test_flatten_bare_list_filters_non_dicts() -> None: + out = _flatten_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}, "not-a-dict"] + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_non_dict_non_list_yields_nothing() -> None: + assert _flatten_legacy_image_config("a string") == [] + + +def test_flatten_single_dict_with_id() -> None: + config = {"id": "a", "file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_single_dict_with_file_only() -> None: + config = {"file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_defaults_images_list() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565", "byte_order": "little_endian"}, + "images": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "byte_order": "little_endian", + } + ] + + +def test_flatten_defaults_images_single_dict() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565"}, + "images": {"id": "a", "file": "x.png"}, + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "rgb565"}] + + +def test_flatten_type_grouped_list() -> None: + out = _flatten_legacy_image_config({"binary": [{"id": "a", "file": "x.png"}]}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_transparency_list() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_transparency_single_dict() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": {"id": "a", "file": "x.png"}}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_dict_without_transparency() -> None: + out = _flatten_legacy_image_config({"binary": {"id": "a", "file": "x.png"}}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_drops_byte_order_for_non_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "binary": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + +def test_flatten_keeps_byte_order_for_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "rgb565": [{"id": "a", "file": "x.png"}], + } + ) + assert out[0][CONF_BYTE_ORDER] == "little_endian" + + +def test_flatten_skips_meta_and_unknown_keys() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [], + "not_a_type": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [] + + +def test_flatten_images_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [{"id": "a", "file": "x.png"}, "not-a-dict"], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png"}, "not-a-dict"]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_scalar_value_is_ignored() -> None: + # A known type key whose value is neither a list nor a dict yields nothing. + assert _flatten_legacy_image_config({"binary": "not-a-list-or-dict"}) == [] + + +def test_flatten_type_grouped_transparency_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}, "not-a-dict"]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_migrate_returns_none_for_new_format() -> None: + assert _migrate_legacy_image_config([{CONF_PLATFORM: "file", "id": "a"}]) is None + + +def test_migrate_legacy_warns_and_prepends_platform( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = _migrate_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}] + ) + assert out == [ + {CONF_PLATFORM: PLATFORM_FILE, "id": "a", "file": "x.png", "type": "binary"} + ] + assert "deprecated" in caplog.text + assert f"platform: {PLATFORM_FILE}" in caplog.text + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + # Recognised legacy shapes -> migrate. + pytest.param([{"id": "a", "file": "x.png"}], True, id="bare_list_of_dicts"), + pytest.param({"id": "a", "file": "x.png"}, True, id="single_image_dict"), + pytest.param({"file": "x.png"}, True, id="single_dict_file_only"), + pytest.param({"defaults": {}, "images": []}, True, id="defaults_images"), + pytest.param({"rgb565": [{"id": "a"}]}, True, id="type_grouped"), + # Shapes the legacy schema never accepted -> not migrated. + pytest.param([], False, id="empty_list"), + pytest.param(["bad"], False, id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], False, id="list_mixed_dict_and_non_dict"), + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], False, id="already_platform_tagged" + ), + pytest.param({"foo": 1}, False, id="dict_unknown_keys"), + pytest.param("a string", False, id="scalar"), + ], +) +def test_is_legacy_image_format(config: object, expected: bool) -> None: + assert _is_legacy_image_format(config) is expected @pytest.mark.parametrize( "config", [ - pytest.param( - { - "id": "image_id", - "file": "image.png", - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - id="single_image_all_options", - ), - pytest.param( - [ - { - "id": "image_id", - "file": "image.png", - "type": "binary", - } - ], - id="list_of_images", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "images": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - id="images_with_defaults", - ), - pytest.param( - { - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ], - }, - id="type_based_organization", - ), - pytest.param( - { - "defaults": { - "type": "binary", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "dither": "none", - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - } - ], - }, - id="type_based_with_defaults", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "alpha_channel", - }, - "binary": { - "opaque": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - }, - id="binary_with_defaults", - ), + pytest.param(["bad"], id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], id="list_mixed"), + pytest.param({"foo": 1}, id="dict_unknown_keys"), ], ) -def test_image_configuration_success( - config: dict[str, Any] | list[dict[str, Any]], +def test_migrate_returns_none_for_invalid_legacy_shapes( + config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Test successful configuration validation.""" - result = CONFIG_SCHEMA(config) - # All valid configurations should return a list of images - assert isinstance(result, list) - for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): - assert all(key in x for x in result), ( - f"Missing key {key} in image configuration" + """Unrecognised shapes are not migrated (and emit no warning) so normal + platform validation surfaces a proper error instead of silently dropping + the offending input.""" + with caplog.at_level(logging.WARNING): + assert _migrate_legacy_image_config(config) is None + assert "deprecated" not in caplog.text + + +# --------------------------- end legacy migration -------------------------- + + +def test_validate_image_final_defaults_to_little_endian() -> None: + out = validate_image_final({CONF_FILE: "x.png"}) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + + +def test_validate_image_final_keeps_little_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final( + {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} ) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + assert "big-endian" not in caplog.text + + +def test_validate_image_final_warns_on_big_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) + assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + assert "big-endian" in caplog.text def test_image_generation( @@ -369,7 +529,7 @@ def test_get_all_image_metadata_empty() -> None: @pytest.fixture def mock_progmem_array(): """Mock progmem_array to avoid needing a proper ID object in tests.""" - with patch("esphome.components.image.cg.progmem_array") as mock_progmem: + with patch("esphome.components.file.image.cg.progmem_array") as mock_progmem: mock_progmem.return_value = MagicMock() yield mock_progmem diff --git a/tests/component_tests/online_image/__init__.py b/tests/component_tests/online_image/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml new file mode 100644 index 00000000000..883876e401b --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -0,0 +1,30 @@ +# New `image:` `platform: online_image` form. Exercises online_image/image.py +# through the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +image: + - platform: online_image + id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml new file mode 100644 index 00000000000..ab0ad472f9b --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -0,0 +1,29 @@ +# Legacy top-level `online_image:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +online_image: + - id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/test_init.py b/tests/component_tests/online_image/test_init.py new file mode 100644 index 00000000000..76b00ff5ff6 --- /dev/null +++ b/tests/component_tests/online_image/test_init.py @@ -0,0 +1,76 @@ +"""Tests for the online_image platform and the legacy `online_image:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.online_image import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_online_image, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/online_image/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_online_image_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_online_image(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_online_image(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: online_image" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_online_image_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `online_image:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("online_image_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "online_image" in caplog.text + assert "deprecated" in caplog.text + + # setup_online_image ran: OnlineImage object constructed and parented. + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp + + +def test_online_image_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: online_image` form generates codegen through the + real platform loader (online_image/image.py) without a deprecation warning.""" + main_cpp = generate_main(component_config_path("online_image_platform_test.yaml")) + + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp diff --git a/tests/components/animation/common.yaml b/tests/components/animation/common.yaml index 8bb2a2f4d88..6790e8439b3 100644 --- a/tests/components/animation/common.yaml +++ b/tests/components/animation/common.yaml @@ -1,23 +1,26 @@ -animation: - - id: rgb565_animation +image: + - platform: animation + id: rgb565_animation file: $component_dir/anim.gif type: RGB565 transparency: opaque resize: 50x50 - - id: rgb_animation + - platform: animation + id: rgb_animation file: $component_dir/anim.apng type: RGB transparency: chroma_key resize: 50x50 - - id: grayscale_animation + - platform: animation + id: grayscale_animation file: $component_dir/anim.apng type: grayscale display: lambda: |- id(rgb565_animation).next_frame(); - id(rgb_animation1).next_frame(); - id(grayscale_animation2).next_frame(); + id(rgb_animation).next_frame(); + id(grayscale_animation).next_frame(); it.image(0, 0, rgb565_animation); - it.image(120, 0, rgb_animation1); - it.image(240, 0, grayscale_animation2); + it.image(120, 0, rgb_animation); + it.image(240, 0, grayscale_animation); diff --git a/tests/components/animation/validate.host.yaml b/tests/components/animation/validate.host.yaml new file mode 100644 index 00000000000..d754f346882 --- /dev/null +++ b/tests/components/animation/validate.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `animation:` form (deprecated; migrates to +# `platform: animation`). Config-only test exercising the deprecation path. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +animation: + - id: legacy_animation + file: $component_dir/anim.gif + type: RGB565 + transparency: opaque + resize: 50x50 diff --git a/tests/components/file/common.yaml b/tests/components/file/common.yaml new file mode 100644 index 00000000000..e95c6b01f6e --- /dev/null +++ b/tests/components/file/common.yaml @@ -0,0 +1,17 @@ +image: + - platform: file + id: file_binary_image + file: ../../pnglogo.png + type: BINARY + dither: FloydSteinberg + - platform: file + id: file_rgb565_image + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel + resize: 50x50 + - platform: file + id: file_mdi_image + file: mdi:alert-circle-outline + type: BINARY + resize: 24x24 diff --git a/tests/components/file/test.esp32-idf.yaml b/tests/components/file/test.esp32-idf.yaml new file mode 100644 index 00000000000..29822d7b4f3 --- /dev/null +++ b/tests/components/file/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: ili9xxx + id: file_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 15 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + +<<: !include common.yaml diff --git a/tests/components/file/test.host.yaml b/tests/components/file/test.host.yaml new file mode 100644 index 00000000000..76f9e5af854 --- /dev/null +++ b/tests/components/file/test.host.yaml @@ -0,0 +1,9 @@ +display: + - platform: sdl + id: file_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +<<: !include common.yaml diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 98190689708..5a8f9383198 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -1,85 +1,104 @@ image: - - id: binary_image + - platform: file + id: binary_image file: ../../pnglogo.png type: BINARY dither: FloydSteinberg - - id: transparent_transparent_image + - platform: file + id: transparent_transparent_image file: ../../pnglogo.png type: BINARY transparency: chroma_key - - id: rgba_image + - platform: file + id: rgba_image file: ../../pnglogo.png type: RGB transparency: alpha_channel resize: 50x50 - - id: rgb24_image + - platform: file + id: rgb24_image file: ../../pnglogo.png type: RGB transparency: chroma_key - - id: rgb_image + - platform: file + id: rgb_image file: ../../pnglogo.png type: RGB transparency: opaque - - id: rgb565_image + - platform: file + id: rgb565_image file: ../../pnglogo.png type: RGB565 transparency: opaque - - id: rgb565_ck_image + - platform: file + id: rgb565_ck_image file: ../../pnglogo.png type: RGB565 transparency: chroma_key - - id: rgb565_alpha_image + - platform: file + id: rgb565_alpha_image file: ../../pnglogo.png type: RGB565 transparency: alpha_channel - - id: grayscale_alpha_image + - platform: file + id: grayscale_alpha_image file: ../../pnglogo.png type: grayscale transparency: alpha_channel resize: 50x50 - - id: grayscale_ck_image + - platform: file + id: grayscale_ck_image file: ../../pnglogo.png type: grayscale transparency: chroma_key - - id: grayscale_image + - platform: file + id: grayscale_image file: ../../pnglogo.png type: grayscale transparency: opaque - - id: web_svg_image + - platform: file + id: web_svg_image file: https://media.esphome.io/logo/logo.svg resize: 256x48 type: BINARY transparency: chroma_key - - id: web_tiff_image + - platform: file + id: web_tiff_image file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff type: RGB resize: 48x48 - - id: web_redirect_image + - platform: file + id: web_redirect_image file: https://media.esphome.io/logo/logo.png type: RGB resize: 48x48 - - id: mdi_alert + - platform: file + id: mdi_alert type: BINARY file: mdi:alert-circle-outline resize: 50x50 - - id: another_alert_icon + - platform: file + id: another_alert_icon file: mdi:alert-outline type: BINARY - - file: mdil:arrange-bring-to-front + - platform: file + file: mdil:arrange-bring-to-front id: mdil_id resize: 50x50 type: binary transparency: chroma_key - - file: mdi:beer + - platform: file + file: mdi:beer id: mdi_id resize: 50x50 type: binary transparency: chroma_key - - file: memory:alert-octagon + - platform: file + file: memory:alert-octagon id: memory_id resize: 50x50 type: binary diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c4493..939a3ac39b6 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -12,12 +12,11 @@ display: invert_colors: true image: - defaults: + - platform: file + id: test_image + file: ../../pnglogo.png type: rgb565 transparency: opaque byte_order: little_endian resize: 50x50 dither: FloydSteinberg - images: - - id: test_image - file: ../../pnglogo.png diff --git a/tests/components/image/test.host.yaml b/tests/components/image/test.host.yaml index aa454970882..455d41d0c21 100644 --- a/tests/components/image/test.host.yaml +++ b/tests/components/image/test.host.yaml @@ -7,43 +7,60 @@ display: height: 480 image: - binary: - - id: binary_image - file: ../../pnglogo.png - dither: FloydSteinberg - - id: transparent_transparent_image - file: ../../pnglogo.png - transparency: chroma_key - rgb: - alpha_channel: - - id: rgba_image - file: ../../pnglogo.png - resize: 50x50 - chroma_key: - - id: rgb24_image - file: ../../pnglogo.png - type: RGB - opaque: - - id: rgb_image - file: ../../pnglogo.png - rgb565: - - id: rgb565_image - file: ../../pnglogo.png - transparency: opaque - - id: rgb565_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: rgb565_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - grayscale: - - id: grayscale_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - resize: 50x50 - - id: grayscale_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: grayscale_image - file: ../../pnglogo.png - transparency: opaque + - platform: file + id: binary_image + file: ../../pnglogo.png + type: binary + dither: FloydSteinberg + - platform: file + id: transparent_transparent_image + file: ../../pnglogo.png + type: binary + transparency: chroma_key + - platform: file + id: rgba_image + file: ../../pnglogo.png + type: rgb + transparency: alpha_channel + resize: 50x50 + - platform: file + id: rgb24_image + file: ../../pnglogo.png + type: RGB + transparency: chroma_key + - platform: file + id: rgb_image + file: ../../pnglogo.png + type: rgb + transparency: opaque + - platform: file + id: rgb565_image + file: ../../pnglogo.png + type: rgb565 + transparency: opaque + - platform: file + id: rgb565_ck_image + file: ../../pnglogo.png + type: rgb565 + transparency: chroma_key + - platform: file + id: rgb565_alpha_image + file: ../../pnglogo.png + type: rgb565 + transparency: alpha_channel + - platform: file + id: grayscale_alpha_image + file: ../../pnglogo.png + type: grayscale + transparency: alpha_channel + resize: 50x50 + - platform: file + id: grayscale_ck_image + file: ../../pnglogo.png + type: grayscale + transparency: chroma_key + - platform: file + id: grayscale_image + file: ../../pnglogo.png + type: grayscale + transparency: opaque diff --git a/tests/components/image/validate-defaults.host.yaml b/tests/components/image/validate-defaults.host.yaml new file mode 100644 index 00000000000..16ea9e7b62c --- /dev/null +++ b/tests/components/image/validate-defaults.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` defaults/images form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path, +# including the per-type byte_order drop when an entry overrides to a non-endian +# type (binary). +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + images: + - id: legacy_defaults_image + file: ../../pnglogo.png + - id: legacy_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/components/image/validate-grouped-single.host.yaml b/tests/components/image/validate-grouped-single.host.yaml new file mode 100644 index 00000000000..0b6ff3d5767 --- /dev/null +++ b/tests/components/image/validate-grouped-single.host.yaml @@ -0,0 +1,24 @@ +# Legacy top-level `image:` structured form using single-dict (non-list) values +# for `images:`, a type group, and a transparency group -- the old `ensure_list` +# accepted a bare dict in each of these places. Deprecated; migrates to +# `platform: file`. Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + images: + id: legacy_images_single_dict + file: ../../pnglogo.png + type: rgb565 + rgb565: + id: legacy_grouped_type_single_dict + file: ../../pnglogo.png + rgb: + alpha_channel: + id: legacy_grouped_transparency_single_dict + file: ../../pnglogo.png diff --git a/tests/components/image/validate-grouped.host.yaml b/tests/components/image/validate-grouped.host.yaml new file mode 100644 index 00000000000..8f85aa7ca52 --- /dev/null +++ b/tests/components/image/validate-grouped.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` type-grouped form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + binary: + - id: legacy_grouped_binary + file: ../../pnglogo.png + rgb: + alpha_channel: + - id: legacy_grouped_rgba + file: ../../pnglogo.png + opaque: + - id: legacy_grouped_rgb + file: ../../pnglogo.png + rgb565: + - id: legacy_grouped_rgb565 + file: ../../pnglogo.png + transparency: chroma_key diff --git a/tests/components/image/validate-single.host.yaml b/tests/components/image/validate-single.host.yaml new file mode 100644 index 00000000000..52a945fb671 --- /dev/null +++ b/tests/components/image/validate-single.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `image:` single-dict form (a bare image dict instead of a +# list; deprecated, migrates to `platform: file`). Config-only test exercising +# the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + id: legacy_single_image + file: ../../pnglogo.png + type: RGB565 + transparency: opaque diff --git a/tests/components/image/validate.host.yaml b/tests/components/image/validate.host.yaml new file mode 100644 index 00000000000..aa821ea7e28 --- /dev/null +++ b/tests/components/image/validate.host.yaml @@ -0,0 +1,18 @@ +# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`). +# Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - id: legacy_list_binary + file: ../../pnglogo.png + type: BINARY + - id: legacy_list_rgb565 + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index fc3cc942172..f71cf63de97 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -2,11 +2,9 @@ wifi: ssid: MySSID password: password1 -# Purposely test that `online_image:` does auto-load `image:` -# Keep the `image:` undefined. -# image: -online_image: - - id: online_binary_image +image: + - platform: online_image + id: online_binary_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: BINARY @@ -21,34 +19,41 @@ online_image: } else { ESP_LOGD("online_image", "Cache miss: fresh download"); } - - id: online_binary_transparent_image + - platform: online_image + id: online_binary_transparent_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png type: BINARY transparency: chroma_key format: png - - id: online_rgba_image + - platform: online_image + id: online_rgba_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: alpha_channel - - id: online_rgb24_image + - platform: online_image + id: online_rgb24_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: chroma_key - - id: online_binary_bmp + - platform: online_image + id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: BINARY - - id: online_rgb_bmp_8bit + - platform: online_image + id: online_rgb_bmp_8bit url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: RGB - - id: online_jpeg_image + - platform: online_image + id: online_jpeg_image url: http://www.faqs.org/images/library.jpg format: JPEG type: RGB - - id: online_jpg_image + - platform: online_image + id: online_jpg_image url: http://www.faqs.org/images/library.jpg format: JPG type: RGB565 diff --git a/tests/components/online_image/validate.host.yaml b/tests/components/online_image/validate.host.yaml new file mode 100644 index 00000000000..f0ba98c65de --- /dev/null +++ b/tests/components/online_image/validate.host.yaml @@ -0,0 +1,22 @@ +# Legacy top-level `online_image:` form (deprecated; migrates to +# `platform: online_image`). Config-only test exercising the deprecation path. +wifi: + ssid: MySSID + password: password1 + +http_request: + +display: + - platform: sdl + id: online_image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +online_image: + - id: legacy_online_image + url: http://www.example.org/example.png + format: PNG + type: RGB565 + resize: 50x50 diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index a06b2da6217..c8b7b63094c 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,6 +1,6 @@ """Unit tests for esphome.config module.""" -from collections.abc import Generator +from collections.abc import Callable, Generator import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config, yaml_util -from esphome.core import CORE +from esphome.core import CORE, AutoLoad +from esphome.types import ConfigType @pytest.fixture @@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: assert "web_server" in platforms, f"Expected web_server platform in {platforms}" +# --------------------------------------------------------------------------- +# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that +# lets a platform component rewrite a pre-platform top-level config. +# --------------------------------------------------------------------------- + + +def _run_load_step( + domain: str, + conf: object, + migrate: Callable[[ConfigType], list | None] | None, +) -> config.Config: + """Run a LoadValidationStep for a platform component with a given migrate hook.""" + component = Mock() + component.is_platform_component = True + component.multi_conf_no_default = False + component.legacy_config_migrate = migrate + + result = config.Config() + with ( + patch("esphome.config.get_component", return_value=component), + patch("esphome.config._process_auto_load"), + patch("esphome.config._process_platform_config"), + ): + config.LoadValidationStep(domain, conf).run(result) + return result + + +def test_legacy_migrate_rewrites_conf() -> None: + """A legacy config that the hook migrates is replaced with the new list.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + + result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate) + + migrate.assert_called_once_with([{"id": "a", "file": "x.png"}]) + assert result["image"] == migrated + + +def test_legacy_migrate_none_keeps_new_format() -> None: + """When the hook returns None the already-new config is left untouched.""" + new_format = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=None) + + result = _run_load_step("image", new_format, migrate) + + migrate.assert_called_once_with(new_format) + assert result["image"] == new_format + + +def test_legacy_migrate_absent_hook_is_noop() -> None: + """A platform component without the hook normalizes without migration.""" + result = _run_load_step("image", {"id": "a"}, None) + + # Bare dict still gets wrapped into a list by the normal normalization path. + assert result["image"] == [{"id": "a"}] + + +def test_legacy_migrate_skipped_for_empty_conf() -> None: + """An empty config short-circuits before the hook is consulted.""" + migrate = Mock(return_value=[{"platform": "file"}]) + + result = _run_load_step("image", [], migrate) + + migrate.assert_not_called() + assert result["image"] == [] + + +def test_legacy_migrate_skipped_for_autoload() -> None: + """An auto-loaded (AutoLoad) config is never migrated.""" + migrate = Mock(return_value=[{"platform": "file"}]) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, migrate) + + migrate.assert_not_called() + # AutoLoad is dict-like, so normalization wraps it into a single-entry list. + assert result["image"] == [auto] + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 2db001710c3ba2c086c3f26420445d17a8a64a71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:41:50 -0400 Subject: [PATCH 334/343] Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 in /.github/actions/restore-python (#17452) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 64b1cabea1c..9d78b2d843f 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the From d4bb20d34b32bf7fa66c1c6369d3b087b9f3668d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:05 -0400 Subject: [PATCH 335/343] Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#17453) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 610e6ed020a..ed6523d7d80 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 99ff7e198aab14ec1cd06f39d70b779c4e66d053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:18 -0400 Subject: [PATCH 336/343] Bump astral-sh/setup-uv from 8.3.1 to 8.3.2 (#17454) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1757959a51f..ebbe7204636 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08241681b3..583e8203ef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -171,7 +171,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -372,7 +372,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 7e0047ee0d0..2f350d09b37 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 99ec2cc00ad8bc5c22d6a03a1ff9728e0f68dbb6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:38 -0400 Subject: [PATCH 337/343] Bump CodSpeedHQ/action from 4.18.2 to 4.18.4 (#17455) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583e8203ef2..6e93b6ece8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@4e969336ab9acd4f6f8d025fdd793292b0835df0 # v4.18.2 + uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 with: run: | . venv/bin/activate From 9088875491377ca2f960b64ebeb641ebad500893 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:48 -0400 Subject: [PATCH 338/343] Bump github/codeql-action/analyze from 4.36.3 to 4.37.0 (#17456) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ed6523d7d80..e718b481e00 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" From 640e0973acc23667e562cf0db1149bc19c7e0d20 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:54:28 +1000 Subject: [PATCH 339/343] [lvgl] Dynamic rotation features (#16773) --- esphome/components/lvgl/__init__.py | 2 + esphome/components/lvgl/automation.py | 27 ++- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/layout.py | 56 +++++ esphome/components/lvgl/lv_validation.py | 13 ++ esphome/components/lvgl/lvgl_esphome.cpp | 28 ++- esphome/components/lvgl/lvgl_esphome.h | 16 ++ esphome/components/lvgl/schemas.py | 2 + esphome/components/lvgl/widgets/__init__.py | 99 ++++++--- .../lvgl/config/layout_update_test.yaml | 92 ++++++++ .../lvgl/test_layout_update.py | 208 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 34 +++ 12 files changed, 538 insertions(+), 41 deletions(-) create mode 100644 tests/component_tests/lvgl/config/layout_update_test.yaml create mode 100644 tests/component_tests/lvgl/test_layout_update.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b758390f0d0..256bf4bb3af 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -148,6 +148,8 @@ SIMPLE_TRIGGERS = ( df.CONF_ON_RESUME, df.CONF_ON_DRAW_START, df.CONF_ON_DRAW_END, + df.CONF_ON_LANDSCAPE, + df.CONF_ON_PORTRAIT, ) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index bf9a3d74ad3..b7c90a5c51a 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -4,7 +4,6 @@ from typing import Any from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg -from esphome.components.display import validate_rotation import esphome.config_validation as cv from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT from esphome.core import Lambda @@ -16,6 +15,7 @@ from .defines import ( CONF_BOTTOM_LAYER, CONF_EDITING, CONF_FREEZE, + CONF_LAYOUT, CONF_LVGL_ID, CONF_MAIN, CONF_OBJ, @@ -29,7 +29,8 @@ from .defines import ( get_options, get_refreshed_widgets, ) -from .lv_validation import lv_bool, lv_milliseconds +from .layout import layout_validator +from .lv_validation import lv_bool, lv_milliseconds, lv_rotation from .lvcode import ( LVGL_COMP_ARG, UPDATE_EVENT, @@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args): def _validate_rotation(value): # Note that we need rotation get_options()[CONF_ROTATION] = True - return validate_rotation(value) + return lv_rotation(value) @automation.register_action( @@ -218,7 +219,8 @@ def _validate_rotation(value): async def lvgl_set_rotation(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) async with LambdaContext(args, where=action_id) as context: - lv_add(lv_comp.set_rotation(config[CONF_ROTATION])) + rotation = await lv_rotation.process(config[CONF_ROTATION]) + lv_add(lv_comp.set_rotation(rotation)) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} +def _layer_update_schema() -> cv.Schema: + """Schema for updating a display layer's styling and layout options.""" + return part_schema(layer_spec.parts).extend( + {cv.Optional(CONF_LAYOUT): layout_validator} + ) + + @automation.register_action( "lvgl.update", LvglAction, @@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} .extend(DISP_BG_SCHEMA) .extend( { - cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts), - cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts), + cv.Optional(CONF_LAYOUT): layout_validator, + cv.Optional(CONF_TOP_LAYER): _layer_update_schema(), + cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(), } ), synchronous=True, @@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) w = widgets[0] async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + # Apply the top-level properties (styles and layout) to the active screen... + await set_obj_properties(get_screen_active(w.var), config) + # ...the deprecated flat `disp_*` background properties... await lvgl_update(w.var, config) + # ...and the `top_layer`/`bottom_layer` keys (styling and layout updates). + await layers_to_code(w.var, config) var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) await cg.register_parented(var, w.var) return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 480ba515d1e..4f734fe20c7 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -760,7 +760,9 @@ CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" CONF_ON_DRAW_START = "on_draw_start" CONF_ON_DRAW_END = "on_draw_end" +CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" +CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" CONF_ON_STOP = "on_stop" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 32304276d3e..fd1f242d862 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -34,6 +34,7 @@ from .defines import ( TYPE_GRID, TYPE_NONE, LvConstant, + add_lv_use, ) from .lv_validation import padding, size @@ -401,6 +402,61 @@ LAYOUT_CLASSES = ( LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES] +# Layout properties that may be changed at runtime via an update action. These +# are limited to simple style properties (set via ``lv_obj_set_style_...``). +# Structural properties are deliberately excluded: +# - the layout ``type``, which determines which options are available to child +# widgets, and +# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells +# that child widgets are placed into. +# Both are fixed at widget creation. +_GRID_LAYOUT_KEYS = ( + CONF_GRID_COLUMN_ALIGN, + CONF_GRID_ROW_ALIGN, +) +_FLEX_LAYOUT_KEYS = ( + CONF_FLEX_FLOW, + CONF_FLEX_ALIGN_MAIN, + CONF_FLEX_ALIGN_CROSS, + CONF_FLEX_ALIGN_TRACK, +) + +LAYOUT_UPDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of, + cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments, + cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of, + cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments, + cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, + cv.Optional(CONF_PAD_ROW): padding, + cv.Optional(CONF_PAD_COLUMN): padding, + } +) + + +def layout_validator(value): + """ + Validate a ``layout:`` value for an update action. Only the layout options + may be changed (not the layout ``type``, which is fixed at widget creation). + :param value: The value of the ``layout:`` key + :return: The validated layout options dict + """ + result = LAYOUT_UPDATE_SCHEMA(value) + if not result: + raise cv.Invalid( + "A layout update must specify at least one layout option", [CONF_LAYOUT] + ) + # Register the relevant layout feature so its LV_USE_* define is emitted even + # when the option is set solely via an update action (whose code generation + # may run after LVGL has finished collecting its used features). + if any(key in result for key in _GRID_LAYOUT_KEYS): + add_lv_use(TYPE_GRID) + if any(key in result for key in _FLEX_LAYOUT_KEYS): + add_lv_use(TYPE_FLEX) + return result + + def append_layout_schema(schema, config: dict): """ Get the child layout schema for a given widget based on its layout type. diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index b588e865d2a..42352b96023 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -331,6 +331,19 @@ lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) +def rotation_degrees(value): + """Validate a display rotation, returning the angle in whole degrees. + + Accepts the four supported rotations, optionally suffixed with "°". + """ + value = cv.string(value).removesuffix("°") + return cv.one_of(0, 90, 180, 270, int=True)(value) + + +# Validator for a display rotation expressed in whole degrees (templatable) +lv_rotation = LValidator(rotation_degrees, cg.int_) + + @schema_extractor("one_of") def size_validator(value): """A size in one axis - one of "size_content", a number (pixels) or a percentage""" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 1db5992389d..b66a9044375 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) { this->rotation_ = rotation; if (this->is_ready()) { this->set_resolution_(); + this->update_orientation_(); lv_obj_update_layout(this->get_screen_active()); lv_obj_invalidate(this->get_screen_active()); } } +void LvglComponent::set_rotation(int angle) { + // Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees. + angle %= 360; + if (angle < 0) + angle += 360; + if (angle % 90 != 0) { + ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle); + return; + } + this->set_rotation(static_cast(angle)); +} + void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const { switch (this->rotation_) { default: @@ -719,6 +732,18 @@ void LvglComponent::set_resolution_() const { } lv_display_set_resolution(this->disp_, width, height); } + +void LvglComponent::update_orientation_() { + // A square display is treated as landscape. + auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT; + if (orientation == this->orientation_) + return; + this->orientation_ = orientation; + auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_; + if (trigger != nullptr) + trigger->trigger(); +} + void LvglComponent::setup() { auto *display = this->displays_[0]; auto rounding = this->draw_rounding; @@ -757,7 +782,7 @@ void LvglComponent::setup() { lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes, this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL); - if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { + if (this->rotation_type_ == ROTATION_SOFTWARE) { this->rotate_buf_ = static_cast(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT if (this->rotate_buf_ == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); @@ -796,6 +821,7 @@ void LvglComponent::setup() { #endif this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0); lv_display_trigger_activity(this->disp_); + this->update_orientation_(); } void LvglComponent::update() { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index dcbf490bce9..9221ab9542e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -185,6 +185,12 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; +enum class Orientation : uint8_t { + UNKNOWN, + LANDSCAPE, + PORTRAIT, +}; + class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; @@ -291,7 +297,11 @@ class LvglComponent final : public PollingComponent { void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } + void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; } + void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; } void set_rotation(display::DisplayRotation rotation); + /// Set the rotation from an angle in degrees. Must be a multiple of 90. + void set_rotation(int angle); display::DisplayRotation get_rotation() const { return this->rotation_; } void rotate_coordinates(int32_t &x, int32_t &y) const; @@ -300,6 +310,9 @@ class LvglComponent final : public PollingComponent { protected: void set_resolution_() const; + // Determine the current orientation from the effective resolution and fire the + // landscape/portrait trigger if it has changed since the last check. + void update_orientation_(); void draw_end_(); // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case @@ -347,6 +360,9 @@ class LvglComponent final : public PollingComponent { Trigger<> *resume_callback_{}; Trigger<> *draw_start_callback_{}; Trigger<> *draw_end_callback_{}; + Trigger<> *landscape_callback_{}; + Trigger<> *portrait_callback_{}; + Orientation orientation_{Orientation::UNKNOWN}; void *rotate_buf_{}; display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES}; RotationType rotation_type_; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 13214d459db..dd4f71a3460 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -55,6 +55,7 @@ from .layout import ( GRID_CELL_SCHEMA, append_layout_schema, grid_alignments, + layout_validator, ) from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr @@ -523,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): ) ), cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(df.CONF_LAYOUT): layout_validator, } ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 4d62c3de057..968db46adc5 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -36,11 +36,10 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + LOGGER, OBJ_FLAGS, PARTS, STATES, - TYPE_FLEX, - TYPE_GRID, LValidator, add_lv_use, call_lambda, @@ -541,44 +540,76 @@ def _size_to_str(value): return str(value) +def _grid_descriptor_array(name: str, specs) -> MockObj: + """Generate a file-scope ``static const`` grid row/column descriptor array + and return a reference to it.""" + values = ",".join(_size_to_str(x) for x in specs) + initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}" + arr_id = ID(name, is_declaration=True, type=lv_coord_t) + return cg.static_const_array(arr_id, cg.RawExpression(initializer)) + + +def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: + """Apply the layout options present in ``layout`` to ``w``. + + Only options actually present are applied, so this works both for widget + creation (where every option is supplied) and for update actions (where the + layout ``type`` and grid structure are fixed and only the style options are + changed). ``base_name`` names the generated grid descriptor arrays and is + only required at creation, when ``grid_rows``/``grid_columns`` are present. + """ + if (pad_row := layout.get(CONF_PAD_ROW)) is not None: + w.set_style(CONF_PAD_ROW, pad_row) + if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: + w.set_style(CONF_PAD_COLUMN, pad_column) + if (rows := layout.get(CONF_GRID_ROWS)) is not None: + w.set_style( + "grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows) + ) + if (columns := layout.get(CONF_GRID_COLUMNS)) is not None: + w.set_style( + "grid_column_dsc_array", + _grid_descriptor_array(f"{base_name}_column_dsc", columns), + ) + if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None: + w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align)) + if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None: + w.set_style(CONF_GRID_ROW_ALIGN, literal(align)) + if (flow := layout.get(CONF_FLEX_FLOW)) is not None: + lv_obj.set_flex_flow(w.obj, literal(flow)) + if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None: + w.set_style("flex_main_place", literal(main)) + if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None: + # Stretch is implemented at creation time by sizing the children; at + # runtime we can only fall back to centering. + if cross == "LV_FLEX_ALIGN_STRETCH": + LOGGER.warning( + "Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead" + ) + cross = "LV_FLEX_ALIGN_CENTER" + w.set_style("flex_cross_place", literal(cross)) + if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None: + w.set_style("flex_track_place", literal(track)) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property if layout := config.get(CONF_LAYOUT): - layout_type: str = layout[CONF_TYPE] - add_lv_use(layout_type) - lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) - if (pad_row := layout.get(CONF_PAD_ROW)) is not None: - w.set_style(CONF_PAD_ROW, pad_row) - if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: - w.set_style(CONF_PAD_COLUMN, pad_column) - if layout_type == TYPE_GRID: - wid = config[CONF_ID] - rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]] - rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}" - row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t) - row_array = cg.static_const_array(row_id, cg.RawExpression(rows)) - w.set_style("grid_row_dsc_array", row_array) - columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]] - columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}" - column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t) - column_array = cg.static_const_array(column_id, cg.RawExpression(columns)) - w.set_style("grid_column_dsc_array", column_array) - w.set_style( - CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)) - ) - w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN))) - if layout_type == TYPE_FLEX: - lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) - main = literal(layout[CONF_FLEX_ALIGN_MAIN]) - cross = layout[CONF_FLEX_ALIGN_CROSS] - if cross == "LV_FLEX_ALIGN_STRETCH": - cross = "LV_FLEX_ALIGN_CENTER" - cross = literal(cross) - track = literal(layout[CONF_FLEX_ALIGN_TRACK]) - lv_obj.set_flex_align(w.obj, main, cross, track) + # The layout `type` (and the grid row/column structure) is only present + # when a widget is created; update actions only change the layout style + # options, leaving the type and grid structure unchanged. + layout_type = layout.get(CONF_TYPE) + if layout_type is not None: + add_lv_use(layout_type) + lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) + # The widget's own id gives the grid descriptor arrays stable names. + base_name = str(config[CONF_ID]) + else: + base_name = None + _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): part = "LV_PART_" + part.upper() diff --git a/tests/component_tests/lvgl/config/layout_update_test.yaml b/tests/component_tests/lvgl/config/layout_update_test.yaml new file mode 100644 index 00000000000..84765a60cfb --- /dev/null +++ b/tests/component_tests/lvgl/config/layout_update_test.yaml @@ -0,0 +1,92 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + pages: + - id: main_page + widgets: + # A flex container whose layout options are changed at runtime. + - obj: + id: flex_box + layout: + type: flex + flex_flow: row + widgets: + - label: + text: a + - label: + text: b + + # A grid container whose alignment options are changed at runtime. + # The grid structure (rows/columns) is fixed here at creation. + - obj: + id: grid_box + layout: + type: grid + grid_rows: [content, content] + grid_columns: [fr(1), fr(1)] + widgets: + - label: + text: c + - label: + text: d + + # Button hosting all of the update actions under test. + - button: + id: btn_actions + on_click: + # Update flex container options (type unchanged). + - lvgl.widget.update: + id: flex_box + layout: + flex_flow: column + flex_align_main: center + flex_align_cross: end + pad_row: 7px + # Update grid container alignment options (structure unchanged). + - lvgl.widget.update: + id: grid_box + layout: + grid_column_align: space_between + grid_row_align: center + # Top-level layout applies to the active screen. + - lvgl.update: + layout: + flex_flow: column + pad_column: 5px + # Layout applied to the top display layer. + - lvgl.update: + top_layer: + layout: + flex_flow: row + # Styling applied to the bottom display layer (exercises the + # layers code path that previously generated no code). + - lvgl.update: + bottom_layer: + bg_color: 0x123456 diff --git a/tests/component_tests/lvgl/test_layout_update.py b/tests/component_tests/lvgl/test_layout_update.py new file mode 100644 index 00000000000..b9730df3792 --- /dev/null +++ b/tests/component_tests/lvgl/test_layout_update.py @@ -0,0 +1,208 @@ +"""Tests for updating LVGL layout options via the update actions. + +The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget +``lvgl..update``) actions can change a container's layout *options* at +runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns`` +structure are fixed at widget creation (they determine the cells/options +available to child widgets), so only the simple style options - those applied +via ``lv_obj_set_style_...`` calls - may be changed. + +These tests cover both the ``layout_validator`` (schema/normalisation) and the +generated C++ for each target: a widget, the active screen (top-level +``lvgl.update``) and the display layers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome.__main__ import generate_cpp_contents +from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses +from esphome.components.lvgl.layout import layout_validator +from esphome.config import read_config +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# layout_validator - schema and normalisation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}), + ({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}), + ({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}), + ( + {"grid_column_align": "space_between"}, + {"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"}, + ), + ({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}), + ({"pad_row": "7px"}, {"pad_row": 7}), + ({"pad_column": "5px"}, {"pad_column": 5}), + ], +) +def test_layout_validator_normalises_options(value: dict, expected: dict) -> None: + """Each supported option is accepted and normalised to its LVGL form.""" + assert layout_validator(value) == expected + + +def test_layout_validator_accepts_multiple_options() -> None: + """Several options may be combined in one update.""" + result = layout_validator( + {"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"} + ) + assert result == { + "flex_flow": "LV_FLEX_FLOW_COLUMN", + "flex_align_main": "LV_FLEX_ALIGN_CENTER", + "pad_row": 4, + } + + +@pytest.mark.parametrize( + "value", + [ + {"type": "flex"}, + {"type": "grid", "grid_column_align": "center"}, + {"grid_rows": 3}, + {"grid_columns": ["fr(1)"]}, + {"grid_rows": [1, 2], "flex_flow": "row"}, + ], +) +def test_layout_validator_rejects_structural_keys(value: dict) -> None: + """The layout type and grid structure are fixed at creation and must not + be changeable via an update action.""" + with pytest.raises(Invalid, match="extra keys not allowed"): + layout_validator(value) + + +def test_layout_validator_rejects_empty() -> None: + """An update must specify at least one layout option.""" + with pytest.raises(Invalid, match="at least one layout option"): + layout_validator({}) + + +def test_layout_validator_registers_flex_use() -> None: + """Validating a flex option registers the flex feature so LV_USE_FLEX is + emitted even when the option is set solely via an update action.""" + layout_validator({"flex_flow": "row"}) + assert TYPE_FLEX in get_lv_uses() + + +def test_layout_validator_registers_grid_use() -> None: + """Validating a grid option registers the grid feature.""" + layout_validator({"grid_column_align": "center"}) + assert TYPE_GRID in get_lv_uses() + + +def test_pad_only_update_registers_no_layout_use() -> None: + """Padding options belong to both layout types, so they alone do not force + either feature on.""" + layout_validator({"pad_row": "4px"}) + uses = get_lv_uses() + assert TYPE_FLEX not in uses + assert TYPE_GRID not in uses + + +# --------------------------------------------------------------------------- +# Generated C++ for the update actions +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared layout-update YAML config once + per module (codegen is relatively expensive).""" + config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None: + """``lvgl.widget.update`` changes only the flex options that are specified, + via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow`` + calls on the target widget.""" + assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp + assert ( + "lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + assert ( + "lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)" + in main_cpp + ) + assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp + + +def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None: + """The update must not re-establish the layout type: ``lv_obj_set_layout`` + is emitted once (at creation) and never from the update action.""" + assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1 + + +def test_widget_flex_update_is_partial(main_cpp: str) -> None: + """An option that was not specified in the update (the track placement) is + only set at creation, not by the partial update.""" + assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1 + + +def test_widget_grid_update_applies_alignments(main_cpp: str) -> None: + """``lvgl.widget.update`` on a grid container changes its alignment + options without touching the grid structure.""" + assert ( + "lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, " + "LV_STATE_DEFAULT)" in main_cpp + ) + assert ( + "lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None: + """The grid row/column descriptor arrays are structural and generated once + at creation; an update must not regenerate them.""" + assert main_cpp.count("grid_box_row_dsc") != 0 + # The descriptor array is declared once and referenced once at creation. + assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc") + assert "lv_obj_set_layout(grid_box," in main_cpp + assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1 + + +def test_top_level_layout_targets_active_screen(main_cpp: str) -> None: + """A top-level ``lvgl.update: { layout: ... }`` applies to the active + screen, not to the LVGL component object.""" + assert ( + "lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)" + in main_cpp + ) + assert ( + "lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_top_layer_layout_applied(main_cpp: str) -> None: + """A layout under ``top_layer`` is applied to the display's top layer.""" + assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp + assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp + + +def test_bottom_layer_styling_applied(main_cpp: str) -> None: + """A ``bottom_layer`` style update generates code (previously the layer + keys of ``lvgl.update`` were silently ignored).""" + assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp + assert ( + "lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), " + "LV_PART_MAIN)" in main_cpp + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d6cd3821f92..f085b62cb6e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -46,6 +46,40 @@ lvgl: - lvgl.display.set_rotation: rotation: 0 lvgl_id: lvgl_id + - lvgl.display.set_rotation: + rotation: !lambda "return 180;" + lvgl_id: lvgl_id + on_landscape: + - logger.log: LVGL display is now landscape + # Re-layout a container in response to orientation changes. The layout type + # and grid structure are fixed at creation; only the style options change. + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: center + grid_row_align: space_between + pad_row: 4px + - lvgl.update: + top_layer: + layout: + flex_flow: row + on_portrait: + - logger.log: LVGL display is now portrait + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: start + pad_row: 2px + # Top-level layout applies to the active screen + - lvgl.update: + layout: + flex_flow: column + pad_row: 8px + - lvgl.update: + top_layer: + layout: + flex_flow: column + flex_align_main: center on_boot: - logger.log: LVGL has started From 7c130fc9706da963904d170cefaf035e7d301ac4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:58:40 +1200 Subject: [PATCH 340/343] [core] Hide build & framework internals from the visual editor (#17449) --- esphome/components/esp32/__init__.py | 28 +++++++++----- esphome/components/esp8266/__init__.py | 8 +++- esphome/components/libretiny/__init__.py | 5 ++- esphome/components/nrf52/__init__.py | 4 +- esphome/components/rp2/__init__.py | 8 +++- esphome/core/config.py | 46 +++++++++++++++++------ tests/component_tests/esp32/test_esp32.py | 34 +++++++++++++++++ tests/unit_tests/core/test_config.py | 31 +++++++++++++++ 8 files changed, 136 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e8d1fe73c7d..7c926fe28e8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1578,16 +1578,20 @@ FRAMEWORK_SCHEMA = cv.Schema( { cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, + cv.Optional(CONF_RELEASE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional(CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_pio_platform_version, + cv.Optional( + CONF_SDKCONFIG_OPTIONS, default={}, visibility=cv.Visibility.YAML_ONLY + ): {cv.string_strict: cv.string_strict}, cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( *LOG_LEVELS_IDF, upper=True ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( *ASSERTION_LEVELS, upper=True @@ -1677,7 +1681,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.Optional( + CONF_COMPONENTS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list( cv.All( cv.Any( cv.All(cv.string_strict, _parse_idf_component), @@ -1777,7 +1783,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( *FLASH_FREQUENCIES, upper=True ), - cv.Optional(CONF_PARTITIONS): cv.Any( + cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any( cv.file_, cv.ensure_list( cv.All( @@ -1801,7 +1807,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, - cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index ab742db0656..0e0e2f77d74 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -202,8 +202,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 079bb32aabb..3fde11b1ebf 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -257,7 +257,10 @@ FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, + # Raw PlatformIO package source — build internal, not a UI field. + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, cv.Optional(CONF_LOGLEVEL, default="warn"): ( cv.one_of(*LT_LOGLEVELS, upper=True) ), diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 692b2637b20..8d522a87407 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -250,7 +250,9 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_VERSION): cv.string_strict, cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional( CONF_ENABLE_OTA_ROLLBACK, default=True diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 21a885a7cfd..fad9d3d25bd 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -244,8 +244,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, diff --git a/esphome/core/config.py b/esphome/core/config.py index 5b95ac3a508..6b24a554874 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -284,14 +284,24 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_COMMENT): cv.All( cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) ), - cv.Required(CONF_BUILD_PATH): cv.string, - cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( + cv.Required(CONF_BUILD_PATH, visibility=cv.Visibility.YAML_ONLY): cv.string, + cv.Optional( + CONF_PLATFORMIO_OPTIONS, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.Any([cv.string], cv.string), } ), - cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), - cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + cv.Optional( + CONF_BUILD_FLAGS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_ENVIRONMENT_VARIABLES, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.string, } @@ -313,12 +323,20 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LoopTrigger), } ), - cv.Optional(CONF_INCLUDES, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_INCLUDES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_INCLUDES_C, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_LIBRARIES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, - cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, + cv.Optional( + CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -338,11 +356,15 @@ CONFIG_SCHEMA = cv.All( ), } ), - cv.Optional(CONF_MIN_VERSION, default=ESPHOME_VERSION): cv.All( - cv.version_number, cv.validate_esphome_version - ), cv.Optional( - CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default + CONF_MIN_VERSION, + default=ESPHOME_VERSION, + visibility=cv.Visibility.ADVANCED, + ): cv.All(cv.version_number, cv.validate_esphome_version), + cv.Optional( + CONF_COMPILE_PROCESS_LIMIT, + default=_compile_process_limit_default, + visibility=cv.Visibility.ADVANCED, ): cv.int_range(min=1, max=get_usable_cpu_count()), cv.Optional(CONF_AREAS, default=[]): cv.ensure_list(AREA_SCHEMA), cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list(DEVICE_SCHEMA), diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d53e119e9f4..dd8881e46f5 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -601,6 +601,40 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig +def test_esp32_build_internals_are_yaml_only() -> None: + """ESP32 raw framework / build inputs are ``YAML_ONLY``. + + The framework block's PlatformIO package pins, raw ESP-IDF + sdkconfig options, the low-level ``advanced`` block, extra IDF + component sources, plus the partition table and toolchain override + on the main schema are build internals — never UI form fields. + User-facing choices (framework type/version, board, variant, …) + stay on the main form. + """ + from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA + + fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema} + for field in ( + "release", + "source", + "platform_version", + "sdkconfig_options", + "advanced", + "components", + ): + assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Framework type/version remain user-facing. + assert fw_markers["type"].visibility is None + assert fw_markers["version"].visibility is None + + main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema} + assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY + # toolchain is a real but rarely-touched override -> advanced disclosure. + assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED + assert main_markers["board"].visibility is None + assert main_markers["flash_size"].visibility is None + + def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b3d87f68577..6fd9f4c22ca 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1307,3 +1307,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: mock_cg.add_library.assert_any_call( "noise-c", None, "https://github.com/esphome/noise-c.git" ) + + +def test_esphome_build_internals_are_yaml_only() -> None: + """Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``. + + These knobs (compiler flags, raw PlatformIO options, C/C++ includes, + libraries, build host parallelism, the min-version gate, …) are not + meaningful as visual-editor form fields and a wrong value breaks the + build, so they must never render in a schema-aware UI. + """ + # CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname). + inner = config.CONFIG_SCHEMA.validators[0].schema + markers = {str(k): k for k in inner} + yaml_only_fields = { + CONF_BUILD_PATH, + "platformio_options", + "build_flags", + "environment_variables", + "includes", + "includes_c", + "libraries", + "debug_scheduler", + } + for field in yaml_only_fields: + assert markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Packaging / build-host knobs are real but rarely-touched overrides: + # surface them under the editor's advanced disclosure, not yaml-only. + for field in ("min_version", "compile_process_limit"): + assert markers[field].visibility is cv.Visibility.ADVANCED, field + # A regular device-config field stays on the main form. + assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None From 8ccf0dbd37f0febd2bc990465a11d2af5bbfb9e2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:04:43 +1000 Subject: [PATCH 341/343] [gsl3670] Add new touchscreen component (#16285) --- CODEOWNERS | 1 + esphome/components/gsl3670/__init__.py | 1 + .../gsl3670/gsl3670_touchscreen.cpp | 167 +++++++++++ .../components/gsl3670/gsl3670_touchscreen.h | 50 ++++ esphome/components/gsl3670/touchscreen.py | 209 ++++++++++++++ esphome/components/touchscreen/__init__.py | 89 ++++-- tests/component_tests/gsl3670/__init__.py | 0 tests/component_tests/gsl3670/test_init.py | 260 ++++++++++++++++++ .../components/gsl3670/test.esp32-s3-idf.yaml | 28 ++ 9 files changed, 780 insertions(+), 25 deletions(-) create mode 100644 esphome/components/gsl3670/__init__.py create mode 100644 esphome/components/gsl3670/gsl3670_touchscreen.cpp create mode 100644 esphome/components/gsl3670/gsl3670_touchscreen.h create mode 100644 esphome/components/gsl3670/touchscreen.py create mode 100644 tests/component_tests/gsl3670/__init__.py create mode 100644 tests/component_tests/gsl3670/test_init.py create mode 100644 tests/components/gsl3670/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 619fc140870..0f43cd97495 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,7 @@ esphome/components/gree/switch/* @nagyrobi esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_tb6612fng/* @max246 esphome/components/growatt_solar/* @leeuwte +esphome/components/gsl3670/* @clydebarrow esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn diff --git a/esphome/components/gsl3670/__init__.py b/esphome/components/gsl3670/__init__.py new file mode 100644 index 00000000000..c58ce8a01e8 --- /dev/null +++ b/esphome/components/gsl3670/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.cpp b/esphome/components/gsl3670/gsl3670_touchscreen.cpp new file mode 100644 index 00000000000..9115130f4a0 --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.cpp @@ -0,0 +1,167 @@ +#include "gsl3670_touchscreen.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::gsl3670 { + +static const char *const TAG = "gsl3670.touchscreen"; +static const size_t MAX_TOUCHES = 3; +// --------------------------------------------------------------------------- +// setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP: +// clear_reg → reset → load_fw → startup_chip → reset → startup_chip +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen..."); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + + this->clear_reg_(); + this->reset_(); + this->load_firmware_(); + this->startup_chip_(); + this->reset_(); + this->startup_chip_(); + + ESP_LOGCONFIG(TAG, "GSL3670 initialised OK"); +} + +void GSL3670Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "GSL3670 Touchscreen:\n" + " X-raw-max: %d\n" + " Y-raw-max: %d\n", + this->x_raw_max_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_); +} + +// --------------------------------------------------------------------------- +// update_touches() – mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::update_touches() { + uint8_t buf[44] = {}; + auto err = this->read_register(0x80, buf, sizeof(buf)); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C read failed (%d)", err); + return; + } + uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES); + + // Build gsl_touch_info exactly as the Seeed driver does + for (uint8_t j = 0; j != finger_num; j++) { + // buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi + auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]); + auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]); + auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f; + ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y); + if (x <= 8192 && y <= 8192) + this->add_raw_touch_position_(id, x, y); + } +} + +// --------------------------------------------------------------------------- +// clear_reg_() – mirrors esp_lcd_touch_gsl3670_clear_reg() +// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::clear_reg_() { + ESP_LOGD(TAG, "clear_reg"); + + // GPIO reset pulse + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0x88, 0x01); + // delay(5); + this->write_reg8_(0xe4, 0x04); + // delay(5); + this->write_reg8_(0xe0, 0x00); + // delay(5); +} + +// --------------------------------------------------------------------------- +// reset_() – mirrors touch_gsl3670_reset() +// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::reset_() { + ESP_LOGD(TAG, "reset"); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0xe4, 0x04); + + uint8_t zeros[4] = {0, 0, 0, 0}; + this->write_reg_(0xbc, zeros, 4); +} + +void GSL3670Touchscreen::load_firmware_() { + if (firmware_ == nullptr || firmware_len_ == 0) { + ESP_LOGW(TAG, "No firmware supplied – skipping"); + return; + } + + ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_); + + static constexpr size_t FW_BLK_SIZE = 128 + 4; + + for (size_t i = 0; i != this->firmware_len_; i++) { + auto offset = i * FW_BLK_SIZE; + uint8_t val = this->firmware_[offset + 0]; + ESP_LOGV(TAG, "Firmware address 0x%02X", val); + this->write_reg_(0xf0, &val, 1); + this->write_reg_(0, this->firmware_ + offset + 4, 128); + } + ESP_LOGD(TAG, "Firmware load complete"); +} + +// --------------------------------------------------------------------------- +// startup_chip_() – mirrors esp_lcd_touch_gsl3670_startup_chip() +// write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::startup_chip_() { + ESP_LOGD(TAG, "startup_chip"); + this->write_reg8_(0xe0, 0x00); + delay(5); +} + +// --------------------------------------------------------------------------- +// I2C helpers +// --------------------------------------------------------------------------- + +bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) { + auto err = this->write_register(reg, data, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err); + return false; + } + return true; +} + +bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); } + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.h b/esphome/components/gsl3670/gsl3670_touchscreen.h new file mode 100644 index 00000000000..3cce074f9b7 --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::gsl3670 { + +// --------------------------------------------------------------------------- +// GSL3670 touchscreen ESPHome component +// --------------------------------------------------------------------------- +class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Supply the firmware table (generated by codegen from the YAML) + void set_firmware(const uint8_t *fw, size_t len) { + this->firmware_ = fw; + this->firmware_len_ = len; + } + + void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; } + + // touchscreen::Touchscreen / Component interface + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + + private: + // ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ---------- + void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence + void reset_(); // GPIO reset + 0xe4/0xbc sequence + void load_firmware_(); // write GSLX670_FW table + void startup_chip_(); // 0x00→0xe0 + gsl_DataInit + + // ---------- I2C helpers ---------- + bool write_reg_(uint8_t reg, const uint8_t *data, size_t len); + bool write_reg8_(uint8_t reg, uint8_t val); + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const uint8_t *firmware_{nullptr}; + size_t firmware_len_{0}; +}; + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py new file mode 100644 index 00000000000..11bb24ce441 --- /dev/null +++ b/esphome/components/gsl3670/touchscreen.py @@ -0,0 +1,209 @@ +"""ESPHome codegen for the gsl3670 touchscreen sub-platform.""" + +import hashlib +import logging +from pathlib import Path + +from esphome import external_files, pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +from esphome.components.const import CONF_SHA256 +from esphome.components.touchscreen import ( + CONF_X_MAX, + CONF_X_MIN, + CONF_Y_MAX, + CONF_Y_MIN, + option_with_default, + touchscreen_schema, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODEL, + CONF_RESET_PIN, + CONF_SWAP_XY, + CONF_URL, +) +from esphome.core import ID + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["touchscreen"] +LOGGER = logging.getLogger(__name__) + +DOMAIN = "gsl3670" + +gsl3670_ns = cg.esphome_ns.namespace("gsl3670") +GSL3670Touchscreen = gsl3670_ns.class_( + "GSL3670Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONF_FIRMWARE = "firmware" + +# Firmware blobs are published as release assets of the companion repository +# rather than vendored into the ESPHome source tree. The default URL/SHA-256 +# for each model point at a pinned release artifact; users may override them +# (or supply a local file via `firmware: { file: ... }`). +FIRMWARE_RELEASE = "v1.0.0" +FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}" + +MODELS = { + "SEEED-RETERMINAL-D1001": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: True, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 872, + CONF_Y_MAX: 1644, + CONF_RESET_PIN: {"xl9535": None, "number": 14}, + CONF_INTERRUPT_PIN: 16, + CONF_FIRMWARE: { + CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin", + CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", + }, + }, + "CUSTOM": {}, +} + +_FW_BLK_SIZE = 128 + 4 + + +def _validate_firmware_data(data: bytes, source: str) -> None: + """Validate the structure of a decoded GSL3670 firmware blob.""" + blk_cnt = len(data) // _FW_BLK_SIZE + if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data): + raise cv.Invalid(f"Firmware file length is incorrect: {source}") + for i in range(0, len(data), _FW_BLK_SIZE): + if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3: + raise cv.Invalid( + f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}" + ) + + +def _cache_path(url: str) -> Path: + """Cache path for a downloaded firmware blob, keyed by URL.""" + key = hashlib.sha256(url.encode()).hexdigest()[:8] + return external_files.compute_local_file_dir(DOMAIN) / key + + +def firmware_path(firmware: dict) -> Path: + """Return the path the firmware bytes will be read from at codegen time.""" + if path := firmware.get(CONF_FILE): + return path + return _cache_path(firmware[CONF_URL]) + + +def _validate_firmware(firmware: dict) -> dict: + """Require a single source, download (with caching), verify and validate.""" + if (CONF_FILE in firmware) == (CONF_URL in firmware): + raise cv.Invalid( + f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided" + ) + + if path := firmware.get(CONF_FILE): + _validate_firmware_data(path.read_bytes(), str(path.absolute())) + return firmware + + url = firmware[CONF_URL] + data = external_files.download_content(url, _cache_path(url)) + + if expected := firmware.get(CONF_SHA256): + actual = hashlib.sha256(data).hexdigest() + if actual.lower() != expected.lower(): + raise cv.Invalid( + f"Firmware SHA-256 mismatch for {url}: " + f"expected {expected.lower()}, got {actual}", + [CONF_SHA256], + ) + else: + LOGGER.warning( + "No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked" + ) + _validate_firmware_data(data, url) + return firmware + + +FIRMWARE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_URL): cv.url, + cv.Optional(CONF_SHA256): cv.string_strict, + cv.Optional(CONF_FILE): cv.file_, + } + ), + _validate_firmware, +) + + +def _config_schema(config): + model_option = { + cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) + } + config = cv.Schema(model_option, extra=True)(config) + defaults = MODELS[config[CONF_MODEL]] + schema = ( + touchscreen_schema(cv.UNDEFINED, False, defaults) + .extend( + { + cv.GenerateID(): cv.declare_id(GSL3670Touchscreen), + option_with_default( + CONF_INTERRUPT_PIN, defaults + ): pins.internal_gpio_input_pin_schema, + option_with_default( + CONF_RESET_PIN, defaults + ): pins.gpio_output_pin_schema, + **model_option, + option_with_default( + CONF_FIRMWARE, defaults, required=True + ): FIRMWARE_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x40)) + .extend(cv.COMPONENT_SCHEMA) + ) + return schema(config) + + +CONFIG_SCHEMA = _config_schema + + +def _read_firmware(config) -> bytes: + path = firmware_path(config[CONF_FIRMWARE]) + data = path.read_bytes() + LOGGER.info( + "Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks", + path.absolute(), + len(data), + len(data) // _FW_BLK_SIZE, + ) + return data + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if CONF_INTERRUPT_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + cg.add(var.set_interrupt_pin(pin)) + + if CONF_RESET_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) + + # Firmware table + data = _read_firmware(config) + fw_array = cg.progmem_array( + ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data) + ) + cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE)) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 4a5c03ace43..cf0c5fca198 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -60,40 +60,79 @@ def validate_calibration(calibration_config): return calibration_config -CALIBRATION_SCHEMA = cv.All( - cv.Schema( - { - cv.Required(CONF_X_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_X_MAX): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MAX): cv.int_range(min=0, max=4095), - } - ), - validate_calibration, -) +def option_with_default(option: str, defaults: dict, required: bool = False): + if option in defaults or not required: + return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) + return cv.Required(option) -def touchscreen_schema(default_touch_timeout=cv.UNDEFINED, calibration_required=False): - calibration = ( - cv.Required(CONF_CALIBRATION) - if calibration_required - else cv.Optional(CONF_CALIBRATION) - ) +_CALIBRATION_KEYS = {CONF_X_MIN, CONF_X_MAX, CONF_Y_MIN, CONF_Y_MAX} +_TRANSFORM_KEYS = {CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y} + + +def _calibration_schema(defaults: dict, required: bool) -> dict: + """ + Generate Calibration schema. If defaults are provided for all suboptions, + the entire calibration config is optional with a populated default value. + Otherwise, it's optional or required as specified. + """ + if _CALIBRATION_KEYS.issubset(defaults): + key = cv.Optional( + CONF_CALIBRATION, + default={k: v for k, v in defaults.items() if k in _CALIBRATION_KEYS}, + ) + elif required: + key = cv.Required(CONF_CALIBRATION) + else: + key = cv.Optional(CONF_CALIBRATION) + return { + key: cv.All( + cv.Schema( + { + option_with_default(x, defaults, True): cv.int_range( + min=0, max=4095 + ) + for x in _CALIBRATION_KEYS + } + ), + validate_calibration, + ) + } + + +def _transform_schema(defaults: dict) -> dict: + if _TRANSFORM_KEYS.issubset(defaults): + key = cv.Optional( + CONF_TRANSFORM, + default={k: v for k, v in defaults.items() if k in _TRANSFORM_KEYS}, + ) + else: + key = cv.Optional(CONF_TRANSFORM) + return { + key: cv.Schema( + { + cv.Optional(x, default=defaults.get(x, False)): cv.boolean + for x in _TRANSFORM_KEYS + } + ) + } + + +def touchscreen_schema( + default_touch_timeout=cv.UNDEFINED, + calibration_required=False, + defaults: dict = None, +) -> cv.Schema: + defaults = defaults or {} return cv.Schema( { cv.GenerateID(CONF_DISPLAY): cv.use_id(display.Display), - cv.Optional(CONF_TRANSFORM): cv.Schema( - { - cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, - } - ), cv.Optional(CONF_TOUCH_TIMEOUT, default=default_touch_timeout): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), - calibration: CALIBRATION_SCHEMA, + **_transform_schema(defaults), + **_calibration_schema(defaults, calibration_required), cv.Optional(CONF_ON_TOUCH): automation.validate_automation(single=True), cv.Optional(CONF_ON_UPDATE): automation.validate_automation(single=True), cv.Optional(CONF_ON_RELEASE): automation.validate_automation(single=True), diff --git a/tests/component_tests/gsl3670/__init__.py b/tests/component_tests/gsl3670/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py new file mode 100644 index 00000000000..3778cf8aa53 --- /dev/null +++ b/tests/component_tests/gsl3670/test_init.py @@ -0,0 +1,260 @@ +"""Tests for the gsl3670 touchscreen configuration validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.const import ( + CONF_CALIBRATION, + CONF_INTERRUPT_PIN, + CONF_MODEL, + CONF_RESET_PIN, + CONF_TRANSFORM, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +VALID_URL = "https://example.com/fw.bin" + + +def _make_firmware(blocks: int = 2) -> bytes: + """Build a structurally valid firmware blob with ``blocks`` blocks. + + Each block is ``_FW_BLK_SIZE`` bytes: a 4-byte header (page address <= 0xEF + followed by the 1/2/3 marker bytes) and a 128-byte payload. + """ + out = bytearray() + for i in range(blocks): + out += bytes([i, 1, 2, 3]) + bytes(gsl._FW_BLK_SIZE - 4) + return bytes(out) + + +def _write_firmware(tmp_path: Path, data: bytes | None = None) -> Path: + """Write firmware bytes to a temp file and return its path.""" + path = tmp_path / "fw.bin" + path.write_bytes(_make_firmware() if data is None else data) + return path + + +# --------------------------------------------------------------------------- +# _validate_firmware_data - blob structure +# --------------------------------------------------------------------------- + + +def test_validate_firmware_data_accepts_valid_blob() -> None: + """A correctly structured blob passes validation.""" + gsl._validate_firmware_data(_make_firmware(3), "test") + + +@pytest.mark.parametrize("length", [0, gsl._FW_BLK_SIZE - 1, gsl._FW_BLK_SIZE + 1]) +def test_validate_firmware_data_rejects_bad_length(length: int) -> None: + """The blob length must be a non-zero multiple of the block size.""" + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware_data(bytes(length), "test") + + +@pytest.mark.parametrize( + "index,value", + [ + (0, 0xF0), # page address must be <= 0xEF + (1, 0x00), # marker byte must be 1 + (2, 0x00), # marker byte must be 2 + (3, 0x00), # marker byte must be 3 + ], +) +def test_validate_firmware_data_rejects_corrupted_header( + index: int, value: int +) -> None: + """A block whose header bytes are wrong is reported as corrupted.""" + data = bytearray(_make_firmware(2)) + # Corrupt the header of the second block. + data[gsl._FW_BLK_SIZE + index] = value + with pytest.raises(cv.Invalid, match="Corrupted firmware at block 1"): + gsl._validate_firmware_data(bytes(data), "test") + + +# --------------------------------------------------------------------------- +# _cache_path / firmware_path +# --------------------------------------------------------------------------- + + +def test_cache_path_is_deterministic_per_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The cache path is derived from (and stable for) the URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + first = gsl._cache_path(VALID_URL) + assert first == gsl._cache_path(VALID_URL) + assert first != gsl._cache_path("https://example.com/other.bin") + assert first.parent == tmp_path + + +def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: + """A ``file`` source is returned as-is, without consulting the cache.""" + path = _write_firmware(tmp_path) + assert gsl.firmware_path({"file": path}) == path + + +def test_firmware_path_uses_cache_for_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A ``url`` source resolves to the cache path for that URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) + + +# --------------------------------------------------------------------------- +# _validate_firmware / FIRMWARE_SCHEMA +# --------------------------------------------------------------------------- + + +def test_firmware_requires_exactly_one_source(tmp_path: Path) -> None: + """Supplying both, or neither, of url/file is an error.""" + path = _write_firmware(tmp_path) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({"url": VALID_URL, "file": path}) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({}) + + +def test_firmware_file_valid(tmp_path: Path) -> None: + """A valid firmware file passes the full FIRMWARE_SCHEMA.""" + path = _write_firmware(tmp_path) + result = gsl.FIRMWARE_SCHEMA({"file": str(path)}) + assert result["file"] == path + + +def test_firmware_file_corrupt_rejected(tmp_path: Path) -> None: + """A file whose contents fail the structural check is rejected.""" + path = _write_firmware(tmp_path, data=b"\x00" * (gsl._FW_BLK_SIZE * 2)) + with pytest.raises(cv.Invalid, match="Corrupted firmware"): + gsl._validate_firmware({"file": path}) + + +def test_firmware_url_downloads_and_validates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A url source downloads the content and validates its structure.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} + + +def test_firmware_url_sha256_mismatch_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A configured SHA-256 that does not match the download is rejected.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): + gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) + + +def test_firmware_url_invalid_structure_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Downloaded content that is not a valid blob is rejected.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr( + gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" + ) + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware({"url": VALID_URL}) + + +# --------------------------------------------------------------------------- +# CONFIG_SCHEMA +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _esp32_core(set_core_config: SetCoreConfigCallable) -> None: + """Configure the core as an ESP32 target for the schema tests.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_config_custom_model_minimal(tmp_path: Path) -> None: + """The CUSTOM model validates with an explicit firmware file and pins.""" + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "custom", + "interrupt_pin": 16, + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "CUSTOM" + assert "id" in result + # The CUSTOM model supplies no transform/calibration defaults. + assert CONF_TRANSFORM not in result + assert CONF_CALIBRATION not in result + + +def test_config_custom_model_requires_firmware() -> None: + """The firmware option is required for the CUSTOM model (no default).""" + with pytest.raises(cv.Invalid, match=r"required key not provided.*firmware"): + gsl.CONFIG_SCHEMA({"model": "custom", "interrupt_pin": 16, "reset_pin": 4}) + + +def test_config_invalid_model_rejected() -> None: + """An unknown model name is rejected.""" + with pytest.raises(cv.Invalid, match="model"): + gsl.CONFIG_SCHEMA({"model": "nonexistent"}) + + +def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None: + """The SEEED model populates transform and calibration defaults. + + ``reset_pin`` is overridden with a plain GPIO so the test does not depend on + the model's default IO-expander pin. + """ + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "seeed-reterminal-d1001", + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "SEEED-RETERMINAL-D1001" + # Transform defaults from the model. + assert result[CONF_TRANSFORM] == { + "swap_xy": True, + "mirror_x": True, + "mirror_y": True, + } + # Calibration defaults from the model. + assert result[CONF_CALIBRATION]["x_min"] == 20 + assert result[CONF_CALIBRATION]["x_max"] == 872 + assert result[CONF_CALIBRATION]["y_min"] == 20 + assert result[CONF_CALIBRATION]["y_max"] == 1644 + # The interrupt pin default (16) is applied without being specified. + assert CONF_INTERRUPT_PIN in result + assert CONF_RESET_PIN in result + + +def test_config_rejects_non_dict() -> None: + """A non-dict configuration is rejected.""" + with pytest.raises(cv.Invalid, match="expected a dictionary"): + gsl.CONFIG_SCHEMA("not a dict") diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..2565d57f130 --- /dev/null +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -0,0 +1,28 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +xl9535: + id: expander + +display: + - platform: mipi_spi + spi_id: spi_bus + model: t-display-s3-pro + +psram: + mode: quad + +touchscreen: + # Firmware downloaded from the model's default release URL and cached. + - platform: gsl3670 + model: seeed-reterminal-d1001 + interrupt_pin: 18 + # Explicit firmware URL + SHA-256 override. + - platform: gsl3670 + model: seeed-reterminal-d1001 + reset_pin: 10 + interrupt_pin: 11 + firmware: + url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin + sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4 From f1622ac96a68af1cd077e41f31f5b64b5450a924 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:53 +1200 Subject: [PATCH 342/343] Bump version to 2026.7.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9f4e20b977f..6f8b6e66644 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.7.0-dev +PROJECT_NUMBER = 2026.7.0b1 # 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 988134fa467..faa716bdd76 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.7.0-dev" +__version__ = "2026.7.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 19e89aa7f222e74256f5630f607935720e668552 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:43:56 -0400 Subject: [PATCH 343/343] [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) --- tests/components/gsl3670/test.esp32-s3-idf.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml index 2565d57f130..48bb9982d9a 100644 --- a/tests/components/gsl3670/test.esp32-s3-idf.yaml +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -7,6 +7,7 @@ xl9535: display: - platform: mipi_spi + id: gsl3670_display spi_id: spi_bus model: t-display-s3-pro @@ -17,10 +18,12 @@ touchscreen: # Firmware downloaded from the model's default release URL and cached. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display interrupt_pin: 18 # Explicit firmware URL + SHA-256 override. - platform: gsl3670 model: seeed-reterminal-d1001 + display: gsl3670_display reset_pin: 10 interrupt_pin: 11 firmware: