From f5c79eeec0f961b0108f0a89ba158f00183cf693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Oct 2026 19:29:34 -0500 Subject: [PATCH 01/26] [qmi8658] Pin motion_id in test config to fix grouped component test conflict (#20075) --- tests/components/qmi8658/common-actions.yaml | 2 ++ tests/components/qmi8658/common.yaml | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/tests/components/qmi8658/common-actions.yaml b/tests/components/qmi8658/common-actions.yaml index b8ab845ef11..62371223cc0 100644 --- a/tests/components/qmi8658/common-actions.yaml +++ b/tests/components/qmi8658/common-actions.yaml @@ -3,5 +3,7 @@ button: name: Clear motion calibration on_press: - motion.clear_calibration: + id: qmi8658_motion - motion.clear_calibration: + id: qmi8658_motion save: true diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml index 7d4de0f97e7..bfb9caff029 100644 --- a/tests/components/qmi8658/common.yaml +++ b/tests/components/qmi8658/common.yaml @@ -3,6 +3,7 @@ sensor: name: "QMI8658 Temperature" - platform: motion + motion_id: qmi8658_motion type: acceleration_x name: "Accel X" accuracy_decimals: 4 @@ -11,44 +12,55 @@ sensor: window_size: 4 send_every: 1 - platform: motion + motion_id: qmi8658_motion type: acceleration_y name: "Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: qmi8658_motion type: acceleration_z name: "Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: qmi8658_motion type: gyroscope_x name: "Gyro X" - platform: motion + motion_id: qmi8658_motion type: gyroscope_y name: "Gyro Y" - platform: motion + motion_id: qmi8658_motion type: gyroscope_z name: "Gyro Z" - platform: motion + motion_id: qmi8658_motion type: angular_rate_x name: "Angular Rate X" - platform: motion + motion_id: qmi8658_motion type: angular_rate_y name: "Angular Rate Y" - platform: motion + motion_id: qmi8658_motion type: angular_rate_z name: "Angular Rate Z" - platform: motion + motion_id: qmi8658_motion type: pitch name: "Pitch" - platform: motion + motion_id: qmi8658_motion type: roll name: "Roll" motion: - platform: qmi8658 + id: qmi8658_motion i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G From 8691a43879ffc00ebcd363910967dca941a16b61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 Oct 2026 06:29:06 -0500 Subject: [PATCH 02/26] [font] Keep the glyph table in flash (#20079) --- esphome/components/font/__init__.py | 3 +- esphome/components/font/font.cpp | 3 +- esphome/components/font/font.h | 14 +++++++++ .../font/config/glyph_table.yaml | 11 +++++++ tests/component_tests/font/test_font.py | 30 ++++++++++++------- 5 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/font/config/glyph_table.yaml diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 918fde5dbdd..a2fad4bc25f 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -726,7 +726,8 @@ async def to_code(config): ) ] - glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) + # constexpr Glyph table in flash; on ESP8266 static_const_array would place it in RAM. + glyphs = cg.progmem_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) font_height = pt_to_px(base_font.size.height) ascender = pt_to_px(base_font.size.ascender) diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index fda9c269e51..1ed946dadb1 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -309,7 +309,8 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo // Unknown char, skip ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point); if (!this->glyphs_.empty()) { - uint8_t glyph_width = this->glyphs_[0].advance; + // Full-width read: a narrowing byte load would fault on a PROGMEM table on ESP8266. + int glyph_width = this->glyphs_[0].advance; display->rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index fa24181bd00..bab7b449ba6 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/color.h" #include "esphome/core/datatypes.h" #include "esphome/core/defines.h" @@ -36,6 +38,18 @@ class Glyph final { int width; int height; }; +// The glyph table lives in flash, read with plain loads, which ESP8266 only allows for whole words. +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +#ifdef USE_ESP8266 +static_assert(alignof(Glyph) == sizeof(uint32_t), "Glyph fields must stay word sized"); +static_assert(sizeof(Glyph) == 7 * sizeof(uint32_t), "Glyph is read from flash with word loads"); +#endif class Font final #ifdef USE_DISPLAY diff --git a/tests/component_tests/font/config/glyph_table.yaml b/tests/component_tests/font/config/glyph_table.yaml new file mode 100644 index 00000000000..54776e6b6e6 --- /dev/null +++ b/tests/component_tests/font/config/glyph_table.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +font: + - file: ../NotoSans-Regular.ttf + id: noto + size: 12 + glyphs: "AB" diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py index 55e27ae84ce..8de0c0a22c0 100644 --- a/tests/component_tests/font/test_font.py +++ b/tests/component_tests/font/test_font.py @@ -4,8 +4,10 @@ Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings are correctly processed through the font configuration pipeline. """ +from collections.abc import Callable import functools from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest @@ -260,15 +262,12 @@ def mock_cg(): with ( patch("esphome.components.font.cg.add_define") as mock_define, patch("esphome.components.font.cg.progmem_array") as mock_progmem, - patch("esphome.components.font.cg.static_const_array") as mock_static, patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar, ): mock_progmem.return_value = MagicMock() - mock_static.return_value = MagicMock() yield { "add_define": mock_define, "progmem_array": mock_progmem, - "static_const_array": mock_static, "new_Pvariable": mock_new_pvar, } @@ -288,13 +287,13 @@ async def test_to_code_long_latin_generates_all_glyphs(mock_cg): mock_cg["add_define"].assert_any_call("USE_FONT") # progmem_array receives the combined bitmap data (non-empty) - mock_cg["progmem_array"].assert_called_once() - bitmap_data = mock_cg["progmem_array"].call_args.args[1] + # bitmap data first, then the glyph table + assert mock_cg["progmem_array"].call_count == 2 + bitmap_data = mock_cg["progmem_array"].call_args_list[0].args[1] assert len(bitmap_data) > 0 - # static_const_array receives one entry per unique glyph - mock_cg["static_const_array"].assert_called_once() - glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + # the glyph table receives one entry per unique glyph + glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1] assert len(glyph_initializer) == glyph_count # new_Pvariable is called with the correct glyph count @@ -314,7 +313,7 @@ async def test_to_code_glyph_entries_contain_expected_fields(mock_cg): await to_code(config) - glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1] for entry in glyph_initializer: assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}" codepoint = entry[0] @@ -332,6 +331,17 @@ async def test_to_code_glyphs_sorted_by_utf8(mock_cg): await to_code(config) - glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1] codepoints = [entry[0] for entry in glyph_initializer] assert codepoints == sorted(codepoints) + + +def test_glyph_table_is_progmem( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The constexpr glyph table goes to flash, not a RAM static const array.""" + main_cpp = generate_main(component_config_path("glyph_table.yaml")) + + assert re.search(r"static constexpr font::Glyph \w+\[\] PROGMEM = ", main_cpp) + assert "static const font::Glyph" not in main_cpp From 2d56559f04d43095467c4a37c46ad5a97e471f18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 Oct 2026 06:36:33 -0500 Subject: [PATCH 03/26] [lvgl] Reject ESP8266 at validation (#20099) --- esphome/components/lvgl/__init__.py | 9 +++++++++ esphome/components/lvgl/lvgl_esphome.cpp | 4 ++-- tests/component_tests/lvgl/test_platforms.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/lvgl/test_platforms.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ca9a317fec0..66aaac5301f 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -48,6 +48,7 @@ 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.types import ConfigType from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -641,7 +642,15 @@ LVGL_TOP_LEVEL_SCHEMA = ( ) +def _not_on_esp8266(config: ConfigType) -> ConfigType: + # ESP8266 does not have enough RAM for LVGL to be practical. + if CORE.is_esp8266: + raise cv.Invalid("LVGL is not supported on ESP8266") + return config + + LVGL_SCHEMA = cv.All( + _not_on_esp8266, container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 90a770ac3b0..466d4000235 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -1037,7 +1037,7 @@ void lv_mem_init() {} void lv_mem_deinit() {} -#if defined(USE_HOST) || defined(USE_RP2) || defined(USE_ESP8266) +#ifndef USE_ESP32 void *lv_malloc_core(size_t size) { auto *ptr = malloc(size); // NOLINT if (ptr == nullptr) { @@ -1053,7 +1053,7 @@ static void *lv_alloc_draw_buf(size_t size, bool internal) { return malloc(size); // NOLINT } -#elif defined(USE_ESP32) +#else static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT static void *lv_alloc_draw_buf(size_t size, bool internal) { diff --git a/tests/component_tests/lvgl/test_platforms.py b/tests/component_tests/lvgl/test_platforms.py new file mode 100644 index 00000000000..ac4da769ee6 --- /dev/null +++ b/tests/component_tests/lvgl/test_platforms.py @@ -0,0 +1,14 @@ +"""LVGL rejects ESP8266 at validation time.""" + +import pytest + +from esphome.components import lvgl +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_esp8266_is_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(Invalid, match="not supported on ESP8266"): + lvgl.CONFIG_SCHEMA({}) From b070c828b3a61ecd33b6eeea8457fa10bd9e2427 Mon Sep 17 00:00:00 2001 From: abderrahmen bejaoui <115119179+abderbejaoui@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:37:03 +0100 Subject: [PATCH 04/26] [es8311] Encode the REG02 MCLK pre-multiplier instead of writing the factor (#20121) --- esphome/components/es8311/es8311.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 0386d84200f..9a4da3744a1 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -92,6 +92,21 @@ uint8_t ES8311::calculate_resolution_value(ES8311Resolution resolution) { } } +/// Encodes the MCLK pre-multiplier for REG02 bits 3-4. The coefficient table stores the multiplier as the factor +/// itself (1, 2, 4 or 8), while the register takes 0, 1, 2 or 3 (ES8311 datasheet, register 0x02 MULT_PRE). +static uint8_t encode_pre_mult(uint8_t pre_mult) { + switch (pre_mult) { + case 2: + return 1; + case 4: + return 2; + case 8: + return 3; + default: + return 0; + } +} + const ES8311Coefficient *ES8311::get_coefficient(uint32_t mclk, uint32_t rate) { for (const auto &coefficient : ES8311_COEFFICIENTS) { if (coefficient.mclk == mclk && coefficient.rate == rate) @@ -127,7 +142,7 @@ bool ES8311::configure_clock_() { ES8311_ERROR_CHECK(this->read_byte(ES8311_REG02_CLK_MANAGER, ®02)); reg02 &= 0x07; reg02 |= (coefficient->pre_div - 1) << 5; - reg02 |= coefficient->pre_mult << 3; + reg02 |= encode_pre_mult(coefficient->pre_mult) << 3; ES8311_ERROR_CHECK(this->write_byte(ES8311_REG02_CLK_MANAGER, reg02)); // Register 0x03 From 63cb75726b60365d8b68d9e0e22164e9bc94af9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 13:06:05 -0500 Subject: [PATCH 05/26] [noise] Use the shared PROGMEM table helper for the PSK (#20101) --- esphome/codegen.py | 1 + esphome/components/api/__init__.py | 2 +- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/noise/__init__.py | 16 +++----- esphome/cpp_generator.py | 37 ++++++++++++++++++- tests/component_tests/ota/test_esphome_ota.py | 2 +- tests/unit_tests/test_cpp_generator.py | 37 +++++++++++++++++++ 7 files changed, 82 insertions(+), 15 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 4de1d8d5c19..daf53db43fd 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -40,6 +40,7 @@ from esphome.cpp_generator import ( # noqa: F401 progmem_array, safe_exp, set_cpp_standard, + shared_progmem_array, statement, static_const_array, static_function, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 40f520a0575..25ebc934586 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -601,7 +601,7 @@ async def to_code(config: ConfigType) -> None: if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None: if key := encryption_config.get(CONF_KEY): - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) + cg.add(var.set_noise_psk(new_psk_progmem(key))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 6ecae24e299..ffd7c509497 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -360,7 +360,7 @@ async def to_code(config: ConfigType) -> None: # Build time key: the ota keeps its own pointer so safe mode, which # has no api server, still has it cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) + cg.add(var.set_noise_psk(new_psk_progmem(key))) elif CONF_ENCRYPTION in api_conf: # Runtime key: found in the api server, or in preferences in safe mode cg.add_define("USE_OTA_ENCRYPTION") diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 19ad36612de..d9880fe6f9c 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,12 +5,11 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] -DOMAIN = "noise" # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. @@ -74,18 +73,13 @@ def static_encryption_key(conf: ConfigType) -> str | None: return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None -def new_psk_progmem(parent_id: ID, key: str) -> MockObj: +def new_psk_progmem(key: str) -> MockObj: """Emit the decoded key as a PROGMEM array; the component keeps a pointer so the key never occupies RAM. Components sharing one key (api and ota) share the array.""" - decoded = decode_encryption_key(key) - arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) - if (array := arrays.get(decoded)) is None: - array = arrays[decoded] = cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decoded), - ) - return array + return cg.shared_progmem_array( + "noise_psk", cg.uint8, list(decode_encryption_key(key)) + ) def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index b0c3533e040..a5ffaefe0be 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,7 +19,12 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import cpp_string_escape, indent, indent_all_but_first_and_last +from esphome.helpers import ( + cpp_string_escape, + ensure_unique_string, + indent, + indent_all_but_first_and_last, +) from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase @@ -463,6 +468,36 @@ def progmem_array(id_, rhs) -> "MockObj": return obj +def shared_progmem_array( + name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True +) -> "MockObj": + """Emit a global PROGMEM array once per distinct type and contents; later calls reuse it. + + The array is ``static constexpr``, so elements must be constant expressions and lambdas + must be captureless. Its name is made unique against every config id and variable. + ``share=False`` always emits a new array, e.g. for lambdas that may keep static state. + """ + from esphome.config import iter_ids + from esphome.config_validation import RESERVED_IDS + + arrays: dict[str, MockObj] = CORE.data.setdefault("shared_progmem_array", {}) + rhs = safe_exp(rhs) + key = f"{type_} {rhs}" + if share and (array := arrays.get(key)) is not None: + return array + used = {str(i) for i, _ in iter_ids(CORE.config)} + used |= {str(i) for i in CORE.variables} + used |= set(RESERVED_IDS) | CORE.loaded_integrations + id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) + # Global, so any scope can use it; anything a lambda references is already declared. + CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs)) + array = MockObj(id_, ".") + CORE.register_variable(id_, array) + if share: + arrays[key] = array + return array + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index cd66503c1bb..77db1499231 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -560,7 +560,7 @@ def test_encryption_offer_codegen( own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key # The api shares the ota's array instead of emitting the same key twice - assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0) + assert main_cpp.count("noise_psk[] PROGMEM") == (1 if own_key else 0) assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) # The noise transport source compiles only when the define is set assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index fa6255b8c9e..06b7c56baeb 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,6 +4,7 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct +from esphome.core import CORE, ID class TestExpressions: @@ -808,3 +809,39 @@ async def test_templatable__lambda_with_std_string() -> None: result = await cg.templatable(lambda_obj, [], ct.std_string) assert isinstance(result, cg.LambdaExpression) + + +class TestSharedProgmemArray: + def test_identical_contents_share_one_array(self) -> None: + CORE.config = {} + a = cg.shared_progmem_array("table", ct.uint8, [1, 2, 3]) + b = cg.shared_progmem_array("table", ct.uint8, [1, 2, 3]) + c = cg.shared_progmem_array("table", ct.uint8, [4]) + assert a is b + assert str(a) != str(c) + assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2 + assert not any("PROGMEM" in str(st) for st in CORE.main_statements) + + def test_share_false_always_emits_a_new_array(self) -> None: + CORE.config = {} + a = cg.shared_progmem_array("table", ct.uint8, [1], share=False) + b = cg.shared_progmem_array("table", ct.uint8, [1], share=False) + assert str(a) != str(b) + assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2 + + def test_same_contents_different_type_are_separate(self) -> None: + CORE.config = {} + a = cg.shared_progmem_array("table", ct.uint8, [1]) + b = cg.shared_progmem_array("table", ct.uint16, [1]) + assert str(a) != str(b) + + def test_name_avoids_config_ids(self) -> None: + CORE.config = {"sensor": [{"id": ID("table", is_declaration=True)}]} + array = cg.shared_progmem_array("table", ct.uint8, [1]) + assert str(array) == "table_2" + + def test_name_avoids_registered_variables(self) -> None: + CORE.config = {} + CORE.register_variable(ID("table", is_declaration=True), cg.MockObj("table")) + array = cg.shared_progmem_array("table", ct.uint8, [1]) + assert str(array) == "table_2" From c6e3a5a16560d5ca2c291b4047cb66ace4bdca55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:37:12 -0500 Subject: [PATCH 06/26] [voice_assistant] Group the one byte members at the end (#20147) --- .../voice_assistant/voice_assistant.h | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index e99ee07099f..ae5eee3e227 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -44,7 +44,7 @@ enum VoiceAssistantFeature : uint32_t { FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6, }; -enum class State { +enum class State : uint8_t { IDLE, START_MICROPHONE, STARTING_MICROPHONE, @@ -96,7 +96,7 @@ struct Configuration { }; #ifdef USE_MEDIA_PLAYER -enum class MediaPlayerResponseState { +enum class MediaPlayerResponseState : uint8_t { IDLE, URL_SENT, PLAYING, @@ -237,6 +237,7 @@ class VoiceAssistant final : public Component { void set_state_(State state); void set_state_(State state, State desired_state); + bool start_udp_socket_(); void signal_stop_(); void start_playback_timeout_(); @@ -280,8 +281,6 @@ class VoiceAssistant final : public Component { Trigger timer_updated_trigger_; Trigger timer_cancelled_trigger_; Trigger &> timer_tick_trigger_; - bool has_timers_{false}; - bool timer_tick_running_{false}; microphone::MicrophoneSource *mic_source_{nullptr}; microphone::MicrophoneSource *mic_source2_{nullptr}; @@ -292,19 +291,12 @@ class VoiceAssistant final : public Component { size_t speaker_buffer_index_{0}; size_t speaker_buffer_size_{0}; size_t speaker_bytes_received_{0}; - bool wait_for_stream_end_{false}; - bool stream_ended_{false}; #endif #ifdef USE_MEDIA_PLAYER media_player::MediaPlayer *media_player_{nullptr}; std::string tts_response_url_; - bool started_streaming_tts_{false}; - - MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; #endif - bool local_output_{false}; - std::string conversation_id_; std::string wake_word_; @@ -324,29 +316,37 @@ class VoiceAssistant final : public Component { // prolonged one can be detected and stopped; 0 means no imbalance is currently being timed. uint32_t audio_channel_stall_start_{0}; - bool use_wake_word_; - uint8_t noise_suppression_level_; - uint8_t auto_gain_; float volume_multiplier_; uint32_t conversation_timeout_; - bool continuous_{false}; - bool silence_detection_; - - bool continue_conversation_{false}; - - State state_{State::IDLE}; - State desired_state_{State::IDLE}; - - AudioMode audio_mode_{AUDIO_MODE_UDP}; - bool udp_socket_running_{false}; - bool start_udp_socket_(); - Configuration config_{}; #ifdef USE_MICRO_WAKE_WORD micro_wake_word::MicroWakeWord *micro_wake_word_{nullptr}; #endif + + // 1 byte members grouped at the end so they share padding instead of each taking a word + State state_{State::IDLE}; + State desired_state_{State::IDLE}; + AudioMode audio_mode_{AUDIO_MODE_UDP}; + bool has_timers_{false}; + bool timer_tick_running_{false}; +#ifdef USE_SPEAKER + bool wait_for_stream_end_{false}; + bool stream_ended_{false}; +#endif +#ifdef USE_MEDIA_PLAYER + MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; + bool started_streaming_tts_{false}; +#endif + bool local_output_{false}; + bool use_wake_word_; + uint8_t noise_suppression_level_; + uint8_t auto_gain_; + bool continuous_{false}; + bool silence_detection_; + bool continue_conversation_{false}; + bool udp_socket_running_{false}; }; extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From 4b5375145ae725010defedad1f435d6bffb1ca5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:38:05 -0500 Subject: [PATCH 07/26] [microphone] Move bits_per_sample_ to the end of MicrophoneSource (#20146) --- esphome/components/microphone/microphone_source.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index d7a33524322..770399fef59 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -34,7 +34,7 @@ class MicrophoneSource final { */ public: MicrophoneSource(Microphone *mic, uint8_t bits_per_sample, int32_t gain_factor, bool passive) - : mic_(mic), bits_per_sample_(bits_per_sample), gain_factor_(gain_factor), passive_(passive) {} + : mic_(mic), gain_factor_(gain_factor), passive_(passive), bits_per_sample_(bits_per_sample) {} /// @brief Enables a channel to be processed through the callback. /// @@ -81,11 +81,11 @@ class MicrophoneSource final { std::shared_ptr> processed_samples_; Microphone *mic_; - uint8_t bits_per_sample_; std::bitset<8> channels_; int32_t gain_factor_; bool enabled_{false}; bool passive_; // Only pass audio if ``mic_`` is already running + uint8_t bits_per_sample_; }; } // namespace esphome::microphone From bccdbc23c4149f0f56d54d5f5e7d866b5d9987df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:38:12 -0500 Subject: [PATCH 08/26] [mixer] Pack has_contributed_ next to pause_state_ (#20144) --- esphome/components/mixer/speaker/mixer_speaker.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 494443d6951..04b0cb94f40 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -107,12 +107,12 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool stop_gracefully_{false}; bool pause_state_{false}; + std::atomic has_contributed_{false}; // Tracks if source has contributed during this session esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing - std::atomic has_contributed_{false}; // Tracks if source has contributed during this session EventGroupHandle_t event_group_{nullptr}; uint32_t stopping_start_ms_{0}; From 2ee13d59acaac3cf301218e018e28afca36a1634 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:38:32 -0500 Subject: [PATCH 09/26] [speaker] Pack mute_state_ next to state_ (#20143) --- esphome/components/speaker/speaker.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index 01e9ca042e0..277b36a171a 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -127,9 +127,9 @@ class Speaker { #endif State state_{STATE_STOPPED}; + bool mute_state_{false}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; - bool mute_state_{false}; #ifdef USE_AUDIO_DAC audio_dac::AudioDac *audio_dac_{nullptr}; From d0473438d973a4cf4c45876b8183321fab66c007 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:38:54 -0500 Subject: [PATCH 10/26] [i2s_audio] Pack pdm_ with the other microphone bools (#20145) --- esphome/components/i2s_audio/microphone/i2s_audio_microphone.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 37895ac4e79..1b17380ed87 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -60,11 +60,11 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon gpio_num_t din_pin_{I2S_GPIO_UNUSED}; i2s_chan_handle_t rx_handle_; - bool pdm_{false}; #if SOC_I2S_SUPPORTS_PDM_RX i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; #endif + bool pdm_{false}; bool correct_dc_offset_; bool locked_driver_{false}; int32_t dc_offset_prev_input_{0}; From 84435e7d81007ab26580ccd6cd83e0c3a082e624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:39:20 -0500 Subject: [PATCH 11/26] [sendspin] Pack mdns_advertised_ next to task_stack_in_psram_ (#20148) --- esphome/components/sendspin/sendspin_hub.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 8ab70d61ef1..ea09dafe689 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -308,6 +308,9 @@ class SendspinHub final : public Component, CallbackManager group_update_callbacks_{}; bool task_stack_in_psram_{false}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool mdns_advertised_{false}; // Last state requested from mdns +#endif // Requested client state, applied from loop(). Empty until the switch restores its state. std::optional enabled_; @@ -320,7 +323,6 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - bool mdns_advertised_{false}; // Last state requested from mdns #endif }; From fb8f679be96fa3915a28ab213d26dc1ba5fd2848 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 14:40:57 -0500 Subject: [PATCH 12/26] [ring_buffer] Use the static ring buffer structure as its handle (#20134) --- .../components/ring_buffer/ring_buffer.cpp | 40 +++++++++++-------- esphome/components/ring_buffer/ring_buffer.h | 4 +- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/esphome/components/ring_buffer/ring_buffer.cpp b/esphome/components/ring_buffer/ring_buffer.cpp index 9604290cf03..dfcc53ebb2e 100644 --- a/esphome/components/ring_buffer/ring_buffer.cpp +++ b/esphome/components/ring_buffer/ring_buffer.cpp @@ -10,8 +10,9 @@ namespace esphome::ring_buffer { static const char *const TAG = "ring_buffer"; RingBuffer::~RingBuffer() { - if (this->handle_ != nullptr) { - vRingbufferDelete(this->handle_); + // create() only builds the ring buffer once storage_ is allocated + if (this->storage_ != nullptr) { + vRingbufferDelete(this->handle_()); RAMAllocator allocator; allocator.deallocate(this->storage_, this->size_); } @@ -31,7 +32,12 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref return nullptr; } - rb->handle_ = xRingbufferCreateStatic(rb->size_, RINGBUF_TYPE_BYTEBUF, rb->storage_, &rb->structure_); + // handle_() assumes the returned handle is &structure_; NULL means the arguments were rejected + if (xRingbufferCreateStatic(rb->size_, RINGBUF_TYPE_BYTEBUF, rb->storage_, &rb->structure_) != rb->handle_()) { + allocator.deallocate(rb->storage_, rb->size_); + rb->storage_ = nullptr; + return nullptr; + } ESP_LOGD(TAG, "Created ring buffer with size %u", len); return rb; @@ -39,16 +45,16 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref void *RingBuffer::receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait) { length = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &length, ticks_to_wait, max_length); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &length, ticks_to_wait, max_length); return buffer_data; } -void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_, item); } +void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_(), item); } size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { size_t bytes_read = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &bytes_read, ticks_to_wait, len); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &bytes_read, ticks_to_wait, len); if (buffer_data == nullptr) { return 0; @@ -56,14 +62,14 @@ size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { std::memcpy(data, buffer_data, bytes_read); - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); if (bytes_read < len) { // Data may have wrapped around, so read a second time to receive the remainder size_t follow_up_bytes_read = 0; size_t bytes_remaining = len - bytes_read; - buffer_data = xRingbufferReceiveUpTo(this->handle_, &follow_up_bytes_read, 0, bytes_remaining); + buffer_data = xRingbufferReceiveUpTo(this->handle_(), &follow_up_bytes_read, 0, bytes_remaining); if (buffer_data == nullptr) { return bytes_read; @@ -71,7 +77,7 @@ size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { std::memcpy((void *) ((uint8_t *) (data) + bytes_read), buffer_data, follow_up_bytes_read); - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); bytes_read += follow_up_bytes_read; } @@ -89,13 +95,13 @@ size_t RingBuffer::write(const void *data, size_t len) { size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait, bool write_partial) { - if (!xRingbufferSend(this->handle_, data, len, ticks_to_wait)) { + if (!xRingbufferSend(this->handle_(), data, len, ticks_to_wait)) { if (!write_partial) { return 0; // Not enough space available and not allowed to write partial data } // Couldn't fit all the data, write what will fit size_t free = std::min(this->free(), len); - if (xRingbufferSend(this->handle_, data, free, 0)) { + if (xRingbufferSend(this->handle_(), data, free, 0)) { return free; } return 0; @@ -105,11 +111,11 @@ size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickT size_t RingBuffer::available() const { UBaseType_t ux_items_waiting = 0; - vRingbufferGetInfo(this->handle_, nullptr, nullptr, nullptr, nullptr, &ux_items_waiting); + vRingbufferGetInfo(this->handle_(), nullptr, nullptr, nullptr, nullptr, &ux_items_waiting); return ux_items_waiting; } -size_t RingBuffer::free() const { return xRingbufferGetCurFreeSize(this->handle_); } +size_t RingBuffer::free() const { return xRingbufferGetCurFreeSize(this->handle_()); } BaseType_t RingBuffer::reset() { // Discards all the available data @@ -119,15 +125,15 @@ BaseType_t RingBuffer::reset() { bool RingBuffer::discard_bytes_(size_t discard_bytes) { size_t bytes_read = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &bytes_read, 0, discard_bytes); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &bytes_read, 0, discard_bytes); if (buffer_data != nullptr) - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); if (bytes_read < discard_bytes) { size_t wrapped_bytes_read = 0; - buffer_data = xRingbufferReceiveUpTo(this->handle_, &wrapped_bytes_read, 0, discard_bytes - bytes_read); + buffer_data = xRingbufferReceiveUpTo(this->handle_(), &wrapped_bytes_read, 0, discard_bytes - bytes_read); if (buffer_data != nullptr) { - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); bytes_read += wrapped_bytes_read; } } diff --git a/esphome/components/ring_buffer/ring_buffer.h b/esphome/components/ring_buffer/ring_buffer.h index 62094899d78..ed2d4b43456 100644 --- a/esphome/components/ring_buffer/ring_buffer.h +++ b/esphome/components/ring_buffer/ring_buffer.h @@ -115,7 +115,9 @@ class RingBuffer { /// @return True if all bytes were successfully discarded, false otherwise bool discard_bytes_(size_t discard_bytes); - RingbufHandle_t handle_{nullptr}; + // xRingbufferCreateStatic() returns the static structure itself as the handle + RingbufHandle_t handle_() const { return const_cast(&this->structure_); } + StaticRingbuffer_t structure_; uint8_t *storage_{nullptr}; size_t size_{0}; From aad7907c37d03b10186d109b9ff653713481053f Mon Sep 17 00:00:00 2001 From: guillempages Date: Sun, 4 Oct 2026 21:55:55 +0200 Subject: [PATCH 13/26] [http_request] Reduce log level for missing header (#18932) --- esphome/components/http_request/http_request.cpp | 3 +-- esphome/components/http_request/http_request.h | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index d45208ed5df..37e0de5d6a0 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -25,11 +25,10 @@ std::string HttpContainer::get_response_header(const std::string &header_name) { auto lower = str_lower_case(header_name); // NOLINT for (const auto &entry : this->response_headers_) { if (entry.name == lower) { - ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); + ESP_LOGV(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); return entry.value; } } - ESP_LOGW(TAG, "No header with name %s found", lower.c_str()); return ""; } diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 71668b8556f..68696118ea8 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -268,6 +268,11 @@ class HttpContainer : public Parented { return !this->is_chunked_ && this->bytes_read_ >= this->content_length; } + /** + * @brief Get the value of a response header by name. + * @param header_name The name of the header to retrieve. + * @return The value of the header if found, otherwise an empty string. + */ std::string get_response_header(const std::string &header_name); protected: From 4f539d33916c2f2bf5eb41a7848a5db88f796583 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:56:34 +1300 Subject: [PATCH 14/26] [homeassistant] Use input_number.set_value for input_number entities (#19965) --- .../homeassistant/number/__init__.py | 8 +++- .../number/homeassistant_number.cpp | 13 ++++++- .../component_tests/homeassistant/__init__.py | 0 .../homeassistant/test_number.py | 32 ++++++++++++++++ .../homeassistant/test_number.yaml | 17 +++++++++ .../test_number_invalid_domain.yaml | 17 +++++++++ tests/components/homeassistant/common.yaml | 3 ++ .../fixtures/api_homeassistant.yaml | 13 +++++++ tests/integration/test_api_homeassistant.py | 38 +++++++++++++++++++ 9 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/homeassistant/__init__.py create mode 100644 tests/component_tests/homeassistant/test_number.py create mode 100644 tests/component_tests/homeassistant/test_number.yaml create mode 100644 tests/component_tests/homeassistant/test_number_invalid_domain.yaml diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index ab1389e13a3..9658d3dc84e 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -7,19 +7,23 @@ from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, homeassistant_ns, setup_home_assistant_entity, + validate_entity_domain, ) CODEOWNERS = ["@landonr"] DEPENDENCIES = ["api"] +SUPPORTED_DOMAINS = ["input_number", "number"] + HomeassistantNumber = homeassistant_ns.class_( "HomeassistantNumber", number.Number, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( number.number_schema(HomeassistantNumber) .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("number", SUPPORTED_DOMAINS), ) diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 965f91d202b..553c2466f84 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -1,5 +1,7 @@ #include "homeassistant_number.h" +#include + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" @@ -80,12 +82,19 @@ void HomeassistantNumber::control(float value) { this->publish_state(value); - static constexpr auto SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); + static constexpr char INPUT_PREFIX[] = "input_"; static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); static constexpr auto VALUE_KEY = StringRef::from_lit("value"); api::HomeassistantActionRequest resp; - resp.service = SERVICE_NAME; + // input_number entities have their own set_value action + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = INPUT_NUMBER_SERVICE_NAME; + } else { + resp.service = NUMBER_SERVICE_NAME; + } resp.data.init(2); auto &entity_id = resp.data.emplace_back(); diff --git a/tests/component_tests/homeassistant/__init__.py b/tests/component_tests/homeassistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/homeassistant/test_number.py b/tests/component_tests/homeassistant/test_number.py new file mode 100644 index 00000000000..d17842307d0 --- /dev/null +++ b/tests/component_tests/homeassistant/test_number.py @@ -0,0 +1,32 @@ +"""Tests for the homeassistant number platform.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +VALID_CONFIG = "tests/component_tests/homeassistant/test_number.yaml" +INVALID_CONFIG = "tests/component_tests/homeassistant/test_number_invalid_domain.yaml" + + +def test_number_and_input_number_entities( + generate_main: Callable[[str | Path], str], +) -> None: + """Both number and input_number entity IDs are accepted and passed to the C++ class.""" + main_cpp = generate_main(VALID_CONFIG) + + assert 'ha_number->set_entity_id("number.some_number");' in main_cpp + assert 'ha_input_number->set_entity_id("input_number.some_input");' in main_cpp + + +def test_number_rejects_unsupported_domain( + capsys: pytest.CaptureFixture[str], +) -> None: + """An entity ID from another domain fails config validation.""" + CORE.config_path = Path(INVALID_CONFIG) + + assert read_config({}) is None + assert "is not supported by the number platform" in capsys.readouterr().out diff --git a/tests/component_tests/homeassistant/test_number.yaml b/tests/component_tests/homeassistant/test_number.yaml new file mode 100644 index 00000000000..bcd8365495e --- /dev/null +++ b/tests/component_tests/homeassistant/test_number.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +host: + +logger: + +api: + +number: + - platform: homeassistant + entity_id: number.some_number + id: ha_number + - platform: homeassistant + entity_id: input_number.some_input + id: ha_input_number diff --git a/tests/component_tests/homeassistant/test_number_invalid_domain.yaml b/tests/component_tests/homeassistant/test_number_invalid_domain.yaml new file mode 100644 index 00000000000..684de606205 --- /dev/null +++ b/tests/component_tests/homeassistant/test_number_invalid_domain.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +host: + +logger: + +api: + +number: + - platform: homeassistant + entity_id: number.some_number + id: ha_number + - platform: homeassistant + entity_id: sensor.foo + id: ha_other_number diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 1099f7ea85e..9bc0defae27 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -71,6 +71,9 @@ number: - platform: homeassistant entity_id: number.hello_world id: ha_hello_world_number + - platform: homeassistant + entity_id: input_number.hello_world + id: ha_hello_world_input_number sensor: - platform: homeassistant diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml index 2d77821ff33..deea19bdeb2 100644 --- a/tests/integration/fixtures/api_homeassistant.yaml +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -21,6 +21,12 @@ api: - number.set: id: ha_number value: 42.5 + - number.set: + id: ha_real_number + value: 12.5 + - number.set: + id: ha_real_input_number + value: 7 - switch.turn_on: ha_switch - switch.turn_off: ha_switch - logger.log: "=== All tests completed ===" @@ -129,6 +135,13 @@ text_sensor: # Number component for testing HA number control number: + # Real homeassistant numbers: the action used depends on the entity domain + - platform: homeassistant + id: ha_real_number + entity_id: number.test_real_number + - platform: homeassistant + id: ha_real_input_number + entity_id: input_number.test_real_input_number - platform: template name: "HA Controlled Number" id: ha_number diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index b4adedf8738..bb99b418135 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -86,6 +86,15 @@ async def test_api_homeassistant( "nonexistent.action_for_error_test": loop.create_future(), # error_test_call } + # Futures keyed by (service, entity_id) for the real homeassistant numbers + ha_real_number_futures = { + ("number.set_value", "number.test_real_number"): loop.create_future(), + ( + "input_number.set_value", + "input_number.test_real_input_number", + ): loop.create_future(), + } + # Future for error message test action_error_received_future = loop.create_future() @@ -102,6 +111,10 @@ async def test_api_homeassistant( if not future.done(): future.set_result(service_call) + key = (service_call.service, service_call.data.get("entity_id")) + if key in ha_real_number_futures and not ha_real_number_futures[key].done(): + ha_real_number_futures[key].set_result(service_call) + # Immediately respond to the error test call so the test can proceed # This needs to happen synchronously so ESPHome receives the response # before logging "=== All tests completed ===" @@ -201,6 +214,16 @@ async def test_api_homeassistant( # Empty state with valid entity should work (use different entity to not interfere with test) client.send_home_assistant_state("sensor.edge_case_empty_state", "", "") + # The real homeassistant numbers reject values outside min/max, which + # default to 0 until Home Assistant provides them + for entity in ( + "number.test_real_number", + "input_number.test_real_input_number", + ): + client.send_home_assistant_state(entity, "min", "0") + client.send_home_assistant_state(entity, "max", "100") + client.send_home_assistant_state(entity, "step", "0.5") + # List entities and services _, services = await client.list_entities_services() @@ -334,6 +357,21 @@ async def test_api_homeassistant( # The value might be formatted with trailing zeros assert float(number_call.data["value"]) == 42.5 + # 7b. Real homeassistant numbers pick the action from the entity domain + real_number_call = await asyncio.wait_for( + ha_real_number_futures[("number.set_value", "number.test_real_number")], + timeout=2.0, + ) + assert float(real_number_call.data["value"]) == 12.5 + + real_input_number_call = await asyncio.wait_for( + ha_real_number_futures[ + ("input_number.set_value", "input_number.test_real_input_number") + ], + timeout=2.0, + ) + assert float(real_input_number_call.data["value"]) == 7 + # 8. HA Switch service calls switch_on_call = await asyncio.wait_for( service_call_futures["switch.turn_on"], timeout=2.0 From 52fc5f2c75dc4e0534d05ed7d3cbcb5c756adbaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 15:25:25 -0500 Subject: [PATCH 15/26] [ld2450] Pack direction_dedup_ after zone_config_ (#20152) --- esphome/components/ld2450/ld2450.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index c4f06ad2248..d6285cce8af 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -176,6 +176,10 @@ class LD2450Component : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; Target target_info_[MAX_TARGETS]; Zone zone_config_[MAX_ZONES]; +#ifdef USE_TEXT_SENSOR + // Byte aligned, so it packs after zone_config_ instead of padding before data_callback_ + std::array, MAX_TARGETS> direction_dedup_{}; +#endif #ifdef USE_NUMBER ESPPreferenceObject pref_; // only used when numbers are in use @@ -194,7 +198,6 @@ class LD2450Component : public Component, public uart::UARTDevice { #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; - std::array, MAX_TARGETS> direction_dedup_{}; #endif LazyCallbackManager data_callback_; From 7421b165e831e081488af93b87d234d2f4811d23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 15:25:28 -0500 Subject: [PATCH 16/26] [rtttl] Pack state_ after wholenote_duration_ (#20151) --- esphome/components/rtttl/rtttl.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 7a32b79b177..055204571e1 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -82,14 +82,14 @@ class Rtttl final : public Component { uint16_t note_duration_{0}; /// The duration of a whole note in milliseconds. uint16_t wholenote_duration_; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. From 474c2791fe757414b534f3b94cd037115a040137 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 15:25:35 -0500 Subject: [PATCH 17/26] [ledc] Pack initialized_ next to bit_depth_ (#20150) --- esphome/components/ledc/ledc_output.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index de63325455b..1df0f675729 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -35,11 +35,11 @@ class LEDCOutput final : public output::FloatOutput, public Component { InternalGPIOPin *pin_; uint8_t channel_{}; uint8_t bit_depth_{}; + bool initialized_ = false; float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; uint32_t last_duty_{UINT32_MAX}; - bool initialized_ = false; }; } // namespace esphome::ledc From fe2e541dc80b8cad4f5a4bfc9d4dcf22a46b1c50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 15:26:21 -0500 Subject: [PATCH 18/26] [template] Pack restore_value_ next to optimistic_ in TemplateNumber (#20149) --- esphome/components/template/number/template_number.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h index e51e858ccf3..2c4932f7e87 100644 --- a/esphome/components/template/number/template_number.h +++ b/esphome/components/template/number/template_number.h @@ -25,8 +25,8 @@ class TemplateNumber final : public number::Number, public PollingComponent { protected: void control(float value) override; bool optimistic_{false}; - float initial_value_{NAN}; bool restore_value_{false}; + float initial_value_{NAN}; Trigger set_trigger_; TemplateLambda f_; From 2f9e37d6a973dc3e65cfe5ae58cff5a1bfce7abf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:00:23 -0500 Subject: [PATCH 19/26] [wifi] Let the empty pending_ flags struct take no space (#20142) --- esphome/components/wifi/wifi_component.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 67913796499..6f03bbe5940 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -983,7 +983,8 @@ class WiFiComponent final : public Component { // Bools and bitfields // Pending listener callbacks deferred from platform callbacks to main loop. - struct { + // Empty when no listener needs deferring (e.g. ESP32 without connect state listeners) + [[no_unique_address]] struct { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Deferred until state machine reaches STA_CONNECTED so wifi.connected // condition returns true in listener automations. From 3a06318b6585321bd479e15edd7cdce91ca55c97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:02:18 -0500 Subject: [PATCH 20/26] [template] Use distinct empty types so both unused select members take no space (#20141) --- esphome/components/template/select/template_select.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 1cc28a36d37..c6a1b8bd088 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -9,7 +9,8 @@ namespace esphome::template_ { -struct Empty {}; +// Distinct per member: two empty members of the same type cannot share an address +template struct Empty {}; class BaseTemplateSelect : public select::Select, public PollingComponent {}; void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, size_t initial_option_index, @@ -59,8 +60,8 @@ class TemplateSelect : public BaseTemplateSelect { if constexpr (RESTORE_VALUE) this->pref_.save(&index); } - [[no_unique_address]] std::conditional_t, Empty> f_{}; - [[no_unique_address]] std::conditional_t pref_{}; + [[no_unique_address]] std::conditional_t, Empty<0>> f_{}; + [[no_unique_address]] std::conditional_t> pref_{}; }; /// Template select with set_action trigger - only instantiated when set_action is configured From a1a4474a019ea2712f3a8660ac67de9f4654a9bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:02:24 -0500 Subject: [PATCH 21/26] [script] Let the empty argument tuple take no space for scripts without parameters (#20140) --- esphome/components/script/script.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index b9ea75e0bcf..97b27af498d 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -254,7 +254,8 @@ template class ScriptExecuteAction, T } Script *script_; - Args args_; + // Empty tuple for scripts without parameters; takes no space then + [[no_unique_address]] Args args_; }; /** Wait for a script to finish before continuing. From 9b83f37b786b7136516c4df80bd9d0e7ee28167d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:02:27 -0500 Subject: [PATCH 22/26] [core] Let the empty Scheduler mutex take no space on single-threaded platforms (#20139) --- esphome/core/scheduler.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 56fc83f12fd..4911bdde74f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -518,7 +518,8 @@ class Scheduler { uint32_t hash_or_id, SchedulerItem::Type type, bool find_first); - Mutex lock_; + // Empty on single-threaded platforms (ESP8266, RP2), so it takes no space there + [[no_unique_address]] Mutex lock_; std::vector items_; std::vector to_add_; From c4aa8fa9c5e58423a94deb41090df0d8072bba9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:02:40 -0500 Subject: [PATCH 23/26] [wifi] Place ap_ before the byte fields so they pack after it (#20138) --- esphome/components/wifi/wifi_component.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 6f03bbe5940..5a0a4d2c7ea 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -874,9 +874,6 @@ class WiFiComponent final : public Component { #ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED Mutex scan_result_lock_; #endif -#ifdef USE_WIFI_AP - WiFiAP ap_; -#endif #ifdef USE_WIFI_IP_STATE_LISTENERS StaticVector ip_state_listeners_; #endif @@ -924,6 +921,9 @@ class WiFiComponent final : public Component { uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py + // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the + // 1-byte members so they pack into its trailing bytes instead of padding after it + WiFiAP ap_; #endif // 1-byte enums and integers From 0dc10d9f30a351dc65a91761a9d02bb90c621e0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:04:44 -0500 Subject: [PATCH 24/26] [improv_ble] Pack the byte fields into the padding after connecting_sta_ (#20137) --- .../components/improv_ble/improv_ble_component.h | 16 ++++++++-------- esphome/components/wifi/wifi_component.h | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/improv_ble/improv_ble_component.h b/esphome/components/improv_ble/improv_ble_component.h index 2552bed69b5..126663ed000 100644 --- a/esphome/components/improv_ble/improv_ble_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -72,9 +72,6 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas improv::Error get_improv_error_state() const { return this->error_state_; } protected: - bool should_start_{false}; - bool setup_complete_{false}; - uint32_t identify_start_{0}; uint32_t identify_duration_; uint32_t authorized_start_{0}; @@ -84,6 +81,14 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas std::vector incoming_data_; wifi::WiFiAP connecting_sta_; + // Single byte fields here fill the alignment gap between connecting_sta_ and the next pointer + improv::State state_{improv::STATE_STOPPED}; + improv::Error error_state_{improv::ERROR_NONE}; + bool should_start_{false}; + bool setup_complete_{false}; + bool status_indicator_state_{false}; + bool advertising_device_name_{false}; + bool advertising_requested_{false}; BLEService *service_{nullptr}; BLECharacteristic *status_{nullptr}; @@ -99,16 +104,11 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas output::BinaryOutput *status_indicator_{nullptr}; #endif - improv::State state_{improv::STATE_STOPPED}; - improv::Error error_state_{improv::ERROR_NONE}; #ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif - bool status_indicator_state_{false}; uint32_t last_name_adv_time_{0}; - bool advertising_device_name_{false}; - bool advertising_requested_{false}; void set_status_indicator_state_(bool state); void update_advertising_type_(); void request_advertising_(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 5a0a4d2c7ea..082c059a14a 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -303,7 +303,7 @@ class WiFiAP { bssid_t bssid_{}; // 6 bytes, all zeros = any/not set uint8_t channel_{0}; // 1 byte, 0 = auto/not set int8_t priority_{0}; // 1 byte - bool hidden_{false}; // 1 byte (+ 3 bytes end padding to 4-byte align) + bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless manual IP or EAP adds a 4 byte aligned member }; class WiFiScanResult { From 8f0e032b5ea048f5e4cb676acf8d97c41e7f16c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:05:15 -0500 Subject: [PATCH 25/26] [esp32_ble] Group the one byte io_cap_ with the other byte fields (#20136) --- esphome/components/esp32_ble/ble.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index fd4fb15ff69..950ae6e0bc1 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -235,14 +235,14 @@ class ESP32BLE final : public Component { #ifdef USE_ESP32_BLE_ADVERTISING BLEAdvertising *advertising_{}; // 4 bytes (pointer) #endif - const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash) - esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) - uint32_t advertising_cycle_time_{}; // 4 bytes + const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash) + uint32_t advertising_cycle_time_{}; // 4 bytes // 2-byte aligned members uint16_t appearance_{0}; // 2 bytes // 1-byte aligned members (grouped together to minimize padding) + esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte #ifdef USE_ESP32_BLE_ADVERTISING From cfbbd13b86df52017ecb4ba537b7c799ae6fede7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:05:29 -0500 Subject: [PATCH 26/26] [remote_receiver] Pack carrier_duty_percent_ next to with_dma_ (#20135) --- esphome/components/remote_receiver/remote_receiver.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 6f93979b183..1c1c473f478 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -88,8 +88,8 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; - bool with_dma_{false}; uint32_t carrier_frequency_{0}; + bool with_dma_{false}; uint8_t carrier_duty_percent_{100}; #endif