From da0c47629a6644d0044e9b6cd1397e1295d34747 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:58:57 -0400 Subject: [PATCH 01/60] [esp32] Bump ESP32 platform to 54.03.21-2 (#10000) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 4 ++-- platformio.ini | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 5dd779effb..30cf982649 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -f84518ea4140c194b21cc516aae05aaa0cf876ab866f89e22e91842df46333ed +6af8b429b94191fe8e239fcb3b73f7982d0266cb5b05ffbc81edaeac1bc8c273 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4ab85a55cd..b183f10d72 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -313,7 +313,7 @@ def _format_framework_espidf_version( RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 2, 1) # The platform-espressif32 version to use for arduino frameworks # - https://github.com/pioarduino/platform-espressif32/releases -ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") +ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "2") # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases @@ -322,7 +322,7 @@ RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION = cv.Version(5, 4, 2) # The platformio/espressif32 version to use for esp-idf frameworks # - https://github.com/platformio/platform-espressif32/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32 -ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") +ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "2") # List based on https://registry.platformio.org/tools/platformio/framework-espidf/versions SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ diff --git a/platformio.ini b/platformio.ini index ab0774b29f..d9f2f879ec 100644 --- a/platformio.ini +++ b/platformio.ini @@ -125,7 +125,7 @@ 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/releases/download/54.03.21-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.2.1/esp32-3.2.1.zip @@ -161,7 +161,7 @@ extra_scripts = 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/releases/download/54.03.21-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.4.2/esp-idf-v5.4.2.zip From 161f51e1f44a008d07e0ac98490f55d1f75165f0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:48:25 +1000 Subject: [PATCH 02/60] [esp32] Fix strapping pin validation for P4 and H2 (#9980) --- esphome/components/esp32/gpio_esp32_h2.py | 9 +---- esphome/components/esp32/gpio_esp32_p4.py | 9 +---- tests/component_tests/mipi_spi/conftest.py | 43 +++++++++++++++++++++ tests/component_tests/mipi_spi/test_init.py | 29 +------------- 4 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 tests/component_tests/mipi_spi/conftest.py diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index 7c3a658b17..f37297764b 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -2,6 +2,7 @@ import logging import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin _ESP32H2_SPI_FLASH_PINS = {6, 7, 15, 16, 17, 18, 19, 20, 21} @@ -15,13 +16,6 @@ _LOGGER = logging.getLogger(__name__) def esp32_h2_validate_gpio_pin(value): if value < 0 or value > 27: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-27)") - if value in _ESP32H2_STRAPPING_PINS: - _LOGGER.warning( - "GPIO%d is a Strapping PIN and should be avoided.\n" - "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", - value, - ) if value in _ESP32H2_SPI_FLASH_PINS: _LOGGER.warning( "GPIO%d is reserved for SPI Flash communication on some ESP32-H2 chip variants.\n" @@ -49,4 +43,5 @@ def esp32_h2_validate_supports(value): if is_input: # All ESP32 pins support input mode pass + check_strapping_pin(value, _ESP32H2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 650d06e108..34d1b3139d 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -2,6 +2,7 @@ import logging import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin _ESP32P4_USB_JTAG_PINS = {24, 25} @@ -13,13 +14,6 @@ _LOGGER = logging.getLogger(__name__) def esp32_p4_validate_gpio_pin(value): if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") - if value in _ESP32P4_STRAPPING_PINS: - _LOGGER.warning( - "GPIO%d is a Strapping PIN and should be avoided.\n" - "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", - value, - ) if value in _ESP32P4_USB_JTAG_PINS: _LOGGER.warning( "GPIO%d is reserved for the USB-Serial-JTAG interface.\n" @@ -40,4 +34,5 @@ def esp32_p4_validate_supports(value): if is_input: # All ESP32 pins support input mode pass + check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py new file mode 100644 index 0000000000..c3070c7965 --- /dev/null +++ b/tests/component_tests/mipi_spi/conftest.py @@ -0,0 +1,43 @@ +"""Tests for mpip_spi configuration validation.""" + +from collections.abc import Callable, Generator + +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 + + +@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 v in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = v + 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 diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index c4c93866ca..fbb3222812 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -9,13 +9,10 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import ( KEY_BOARD, - KEY_ESP32, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, - VARIANTS, ) -from esphome.components.esp32.gpio import validate_gpio_pin from esphome.components.mipi import CONF_NATIVE_HEIGHT from esphome.components.mipi_spi.display import ( CONF_BUS_MODE, @@ -32,8 +29,6 @@ from esphome.const import ( CONF_WIDTH, PlatformFramework, ) -from esphome.core import CORE -from esphome.pins import internal_gpio_pin_number from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -43,28 +38,6 @@ def run_schema_validation(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) -@pytest.fixture -def choose_variant_with_pins() -> Callable[..., 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: int | str | None) -> None: - for v in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = v - for pin in pins: - if pin is not None: - pin = internal_gpio_pin_number(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - - return chooser - - @pytest.mark.parametrize( ("config", "error_match"), [ @@ -315,7 +288,7 @@ def test_custom_model_with_all_options( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], - choose_variant_with_pins: Callable[..., None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined display models validate successfully with appropriate defaults.""" set_core_config( From d4ff1bcf5c48ab109f4e8076421a59acfc4f6399 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 14:15:12 -1000 Subject: [PATCH 03/60] [bluetooth_proxy] Implement dynamic service batching based on MTU constraints (#10001) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../bluetooth_proxy/bluetooth_connection.cpp | 234 ++++++++++++------ .../bluetooth_proxy/bluetooth_proxy.h | 1 - 2 files changed, 152 insertions(+), 83 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4f312fce30..fd1324dcdc 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -37,6 +37,37 @@ static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +// Helper to estimate service size before fetching all data +/** + * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. + * + * @param char_count The number of characteristics in the service. + * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. + * @return The estimated size of the service in bytes. + * + * This function calculates the size of a Bluetooth service by considering: + * - A service overhead, which depends on whether efficient UUIDs are used. + * - The size of each characteristic, assuming 128-bit UUIDs for safety. + * - The size of descriptors, assuming one 128-bit descriptor per characteristic. + */ +static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + // Always assume 128-bit UUIDs for characteristics to be safe + size_t char_size = CHAR_SIZE_128BIT; + // Assume one 128-bit descriptor per characteristic + size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; + + return service_overhead + (char_size + desc_size) * char_count; +} + bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -95,16 +126,21 @@ void BluetoothConnection::send_service_for_discovery_() { // Check if client supports efficient UUIDs bool use_efficient_uuids = this->supports_efficient_uuids_(); - // Prepare response for up to 3 services + // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; - // Process up to 3 services in this iteration - uint8_t services_to_process = - std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); - resp.services.reserve(services_to_process); + // Dynamic batching based on actual size + // Conservative MTU limit for API messages (accounts for WPA3 overhead) + static constexpr size_t MAX_PACKET_SIZE = 1360; - for (int service_idx = 0; service_idx < services_to_process; service_idx++) { + // Keep running total of actual message size + size_t current_size = 0; + api::ProtoSize size; + resp.calculate_size(size); + current_size = size.get_size(); + + while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; uint16_t service_count = 1; esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, @@ -118,15 +154,7 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - this->send_service_++; - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - // Get the number of characteristics directly with one call + // Get the number of characteristics BEFORE adding to response uint16_t total_char_count = 0; esp_gatt_status_t char_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, @@ -139,91 +167,133 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - if (total_char_count == 0) { - // No characteristics, continue to next service - continue; + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + // This service likely won't fit, send current batch + break; } - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } + // Now add the service since we know it will likely fit + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + service_resp.handle = service_result.start_handle; - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic - continue; - } - - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + if (total_char_count > 0) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); this->send_service_ = DONE_SENDING_SERVICES; return; } - if (desc_count == 0) { - break; // No more descriptors + if (char_count == 0) { + break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", + this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } + } // end if (total_char_count > 0) + + // Calculate the actual size of just this service + api::ProtoSize service_sizer; + service_resp.calculate_size(service_sizer); + size_t service_size = service_sizer.get_size() + 1; // +1 for field tag + + // Check if adding this service would exceed the limit + if (current_size + service_size > MAX_PACKET_SIZE) { + // We would go over - pop the last service if we have more than one + if (resp.services.size() > 1) { + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", + this->connection_index_, this->address_str().c_str(), this->send_service_, current_size, service_size, + MAX_PACKET_SIZE); + // Don't increment send_service_ - we'll retry this service in next batch + } else { + // This single service is too large, but we have to send it anyway + ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size); + // Increment so we don't get stuck + this->send_service_++; + } + // Send what we have + break; } + + // Now we know we're keeping this service, add its size + current_size += service_size; + // Successfully added this service, increment counter + this->send_service_++; } - // Send the message with 1-3 services + // Send the message with dynamically batched services api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b33460339b..d249515fdf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -22,7 +22,6 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; -static const uint8_t MAX_SERVICES_PER_BATCH = 3; using namespace esp32_ble_client; From 412f4ac341bd047d958849beb76e608534bfa105 Mon Sep 17 00:00:00 2001 From: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Date: Fri, 1 Aug 2025 02:28:22 +0200 Subject: [PATCH 04/60] [midea] Use c++17 constexpr and inline static in IrFollowMeData (#10002) --- esphome/components/midea/ir_transmitter.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index eba8fc87f7..a16aed2e72 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -54,15 +54,15 @@ class IrFollowMeData : public IrData { void set_fahrenheit(bool val) { this->set_mask_(2, val, 32); } protected: - static const uint8_t MIN_TEMP_C = 0; - static const uint8_t MAX_TEMP_C = 37; + inline static constexpr uint8_t MIN_TEMP_C = 0; + inline static constexpr uint8_t MAX_TEMP_C = 37; // see // https://github.com/crankyoldgit/IRremoteESP8266/blob/9bdf8abcb465268c5409db99dc83a26df64c7445/src/ir_Midea.h#L116 - static const uint8_t MIN_TEMP_F = 32; + inline static constexpr uint8_t MIN_TEMP_F = 32; // see // https://github.com/crankyoldgit/IRremoteESP8266/blob/9bdf8abcb465268c5409db99dc83a26df64c7445/src/ir_Midea.h#L117 - static const uint8_t MAX_TEMP_F = 99; + inline static constexpr uint8_t MAX_TEMP_F = 99; }; class IrSpecialData : public IrData { From 549b0d12b6f2b6701a3f7dc759da663fc07a291a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:19:32 +1000 Subject: [PATCH 05/60] [image] Improve schemas (#9791) --- esphome/components/animation/__init__.py | 25 ++- esphome/components/image/__init__.py | 205 ++++++++++-------- .../image/config/image_test.yaml | 8 +- tests/component_tests/image/test_init.py | 62 +++++- 4 files changed, 196 insertions(+), 104 deletions(-) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index f73b8ef08f..c4ac7adb23 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -34,17 +34,20 @@ SetFrameAction = animation_ns.class_( "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) ) -CONFIG_SCHEMA = 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, - } - ), - }, +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, ) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 99646c9f7e..f880b5f736 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -108,6 +108,24 @@ class ImageEncoder: :return: """ + @classmethod + def is_endian(cls) -> bool: + """ + Check if the image encoder supports endianness configuration + """ + return getattr(cls, "set_big_endian", None) is not None + + @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): """ @@ -446,13 +464,14 @@ def validate_type(image_types): return validate -def validate_settings(value): +def validate_settings(value, path=()): """ Validate the settings for a single image configuration. """ conf_type = value[CONF_TYPE] type_class = IMAGE_TYPE[conf_type] - transparency = value[CONF_TRANSPARENCY].lower() + + transparency = value.get(CONF_TRANSPARENCY, CONF_OPAQUE).lower() if transparency not in type_class.allow_config: raise cv.Invalid( f"Image format '{conf_type}' cannot have transparency: {transparency}" @@ -464,11 +483,10 @@ def validate_settings(value): and CONF_INVERT_ALPHA not in type_class.allow_config ): raise cv.Invalid("No alpha channel to invert") - if value.get(CONF_BYTE_ORDER) is not None and not callable( - getattr(type_class, "set_big_endian", None) - ): + if value.get(CONF_BYTE_ORDER) is not None and not type_class.is_endian(): raise cv.Invalid( - f"Image format '{conf_type}' does not support byte order configuration" + f"Image format '{conf_type}' does not support byte order configuration", + path=path, ) if file := value.get(CONF_FILE): file = Path(file) @@ -479,7 +497,7 @@ def validate_settings(value): Image.open(file) except UnidentifiedImageError as exc: raise cv.Invalid( - f"File can't be opened as image: {file.absolute()}" + f"File can't be opened as image: {file.absolute()}", path=path ) from exc return value @@ -499,6 +517,10 @@ OPTIONS_SCHEMA = { 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(), +} + +DEFAULTS_SCHEMA = { + **OPTIONS_SCHEMA, cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), } @@ -510,47 +532,61 @@ IMAGE_SCHEMA_NO_DEFAULTS = { **{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS}, } -BASE_SCHEMA = cv.Schema( +IMAGE_SCHEMA = cv.Schema( { **IMAGE_ID_SCHEMA, **OPTIONS_SCHEMA, - } -).add_extra(validate_settings) - -IMAGE_SCHEMA = BASE_SCHEMA.extend( - { cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), } ) +def apply_defaults(image, defaults, path): + """ + Apply defaults to an image configuration + """ + type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) + if type is None: + raise cv.Invalid( + "Type is required either in the image config or in the defaults", path=path + ) + type_class = IMAGE_TYPE[type] + config = { + **{key: image.get(key, defaults.get(key)) for key in type_class.get_options()}, + **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, + CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)), + } + validate_settings(config, path) + return config + + def validate_defaults(value): """ - Validate the options for images with defaults + Apply defaults to the images in the configuration and flatten to a single list. """ defaults = value[CONF_DEFAULTS] result = [] - for index, image in enumerate(value[CONF_IMAGES]): - type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) - if type is None: - raise cv.Invalid( - "Type is required either in the image config or in the defaults", - path=[CONF_IMAGES, index], - ) - type_class = IMAGE_TYPE[type] - # A default byte order should be simply ignored if the type does not support it - available_options = [*OPTIONS] - if ( - not callable(getattr(type_class, "set_big_endian", None)) - and CONF_BYTE_ORDER not in image - ): - available_options.remove(CONF_BYTE_ORDER) - config = { - **{key: image.get(key, defaults.get(key)) for key in available_options}, - **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, - } - validate_settings(config) - result.append(config) + # Apply defaults to the images: list and add the list entries to the result + for index, image in enumerate(value.get(CONF_IMAGES, [])): + result.append(apply_defaults(image, defaults, [CONF_IMAGES, index])) + + # Apply defaults to images under the type keys and add them to the result + for image_type, type_config in value.items(): + type_upper = image_type.upper() + if type_upper not in IMAGE_TYPE: + continue + type_class = IMAGE_TYPE[type_upper] + if isinstance(type_config, list): + # If the type is a list, apply defaults to each entry + for index, image in enumerate(type_config): + result.append(apply_defaults(image, defaults, [image_type, index])) + else: + # Handle transparency options for the type + for trans_type in set(type_class.allow_config).intersection(type_config): + for index, image in enumerate(type_config[trans_type]): + result.append( + apply_defaults(image, defaults, [image_type, trans_type, index]) + ) return result @@ -562,16 +598,20 @@ def typed_image_schema(image_type): cv.Schema( { cv.Optional(t.lower()): cv.ensure_list( - BASE_SCHEMA.extend( - { - cv.Optional( - CONF_TRANSPARENCY, default=t - ): validate_transparency((t,)), - cv.Optional(CONF_TYPE, default=image_type): validate_type( - (image_type,) - ), - } - ) + { + **IMAGE_ID_SCHEMA, + **{ + cv.Optional(key): OPTIONS_SCHEMA[key] + for key in OPTIONS + if key != CONF_TRANSPARENCY + }, + cv.Optional( + CONF_TRANSPARENCY, default=t + ): validate_transparency((t,)), + cv.Optional(CONF_TYPE, default=image_type): validate_type( + (image_type,) + ), + } ) for t in IMAGE_TYPE[image_type].allow_config.intersection( TRANSPARENCY_TYPES @@ -580,46 +620,44 @@ def typed_image_schema(image_type): ), # Allow a default configuration with no transparency preselected cv.ensure_list( - BASE_SCHEMA.extend( - { - cv.Optional( - CONF_TRANSPARENCY, default=CONF_OPAQUE - ): validate_transparency(), - cv.Optional(CONF_TYPE, default=image_type): validate_type( - (image_type,) - ), - } - ) + { + **IMAGE_SCHEMA_NO_DEFAULTS, + cv.Optional(CONF_TYPE, default=image_type): validate_type( + (image_type,) + ), + } ), ) # The config schema can be a (possibly empty) single list of images, -# or a dictionary of image types each with a list of images -# or a dictionary with keys `defaults:` and `images:` +# or a dictionary with optional keys `defaults:`, `images:` and the image types -def _config_schema(config): - if isinstance(config, list): - return cv.Schema([IMAGE_SCHEMA])(config) - if not isinstance(config, dict): +def _config_schema(value): + if isinstance(value, list) or ( + isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value) + ): + return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value) + if not isinstance(value, dict): raise cv.Invalid( - "Badly formed image configuration, expected a list or a dictionary" + "Badly formed image configuration, expected a list or a dictionary", ) - if CONF_DEFAULTS in config or CONF_IMAGES in config: - return validate_defaults( - cv.Schema( - { - cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA, - cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS), - } - )(config) - ) - if CONF_ID in config or CONF_FILE in config: - return cv.ensure_list(IMAGE_SCHEMA)([config]) - return cv.Schema( - {cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE} - )(config) + return cv.All( + cv.Schema( + { + cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA, + cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list( + { + **IMAGE_SCHEMA_NO_DEFAULTS, + cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ), + **{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}, + } + ), + validate_defaults, + )(value) CONFIG_SCHEMA = _config_schema @@ -668,7 +706,7 @@ async def write_image(config, all_frames=False): else Image.Dither.FLOYDSTEINBERG ) type = config[CONF_TYPE] - transparency = config[CONF_TRANSPARENCY] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) invert_alpha = config[CONF_INVERT_ALPHA] frame_count = 1 if all_frames: @@ -699,14 +737,9 @@ async def write_image(config, all_frames=False): async def to_code(config): - if isinstance(config, list): - for entry in config: - await to_code(entry) - elif CONF_ID not in config: - for entry in config.values(): - await to_code(entry) - else: - prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + # By now the config should be a simple list. + for entry in config: + prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) cg.new_Pvariable( - config[CONF_ID], prog_arr, width, height, image_type, trans_value + entry[CONF_ID], prog_arr, width, height, image_type, trans_value ) diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index 3ff1260bd0..c34e0993a5 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -5,10 +5,12 @@ esp32: board: esp32s3box image: - - file: image.png - byte_order: little_endian - id: cat_img + defaults: type: rgb565 + byte_order: little_endian + images: + - file: image.png + id: cat_img spi: mosi_pin: 6 diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index d8a883d32f..f0b132cef8 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -9,7 +9,8 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.image import CONFIG_SCHEMA +from esphome.components.image import CONF_TRANSPARENCY, CONFIG_SCHEMA +from esphome.const import CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE @pytest.mark.parametrize( @@ -22,12 +23,12 @@ from esphome.components.image import CONFIG_SCHEMA ), pytest.param( {"id": "image_id", "type": "rgb565"}, - r"required key not provided @ data\[0\]\['file'\]", + r"required key not provided @ data\['file'\]", id="missing_file", ), pytest.param( {"file": "image.png", "type": "rgb565"}, - r"required key not provided @ data\[0\]\['id'\]", + r"required key not provided @ data\['id'\]", id="missing_id", ), pytest.param( @@ -160,13 +161,66 @@ def test_image_configuration_errors( }, 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", + ), ], ) def test_image_configuration_success( config: dict[str, Any] | list[dict[str, Any]], ) -> None: """Test successful configuration validation.""" - CONFIG_SCHEMA(config) + 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" + ) def test_image_generation( From 7a4738ec4ec1d75fa989fb1e417ea41d61033651 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 1 Aug 2025 03:49:39 +0200 Subject: [PATCH 06/60] [nrf52] add adc (#9321) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/adc/__init__.py | 6 + esphome/components/adc/adc_sensor.h | 23 +- esphome/components/adc/adc_sensor_common.cpp | 14 +- esphome/components/adc/adc_sensor_esp32.cpp | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 2 +- .../components/adc/adc_sensor_libretiny.cpp | 2 +- esphome/components/adc/adc_sensor_rp2040.cpp | 2 +- esphome/components/adc/adc_sensor_zephyr.cpp | 207 ++++++++++++++++++ esphome/components/adc/sensor.py | 54 ++++- esphome/components/nrf52/const.py | 14 ++ esphome/components/nrf52/gpio.py | 30 ++- esphome/components/zephyr/__init__.py | 25 ++- esphome/components/zephyr/const.py | 2 + script/helpers_zephyr.py | 1 + tests/components/adc/test.nrf52-adafruit.yaml | 23 ++ tests/components/adc/test.nrf52-mcumgr.yaml | 23 ++ 16 files changed, 412 insertions(+), 18 deletions(-) create mode 100644 esphome/components/adc/adc_sensor_zephyr.cpp create mode 100644 tests/components/adc/test.nrf52-adafruit.yaml create mode 100644 tests/components/adc/test.nrf52-mcumgr.yaml diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 1232d9677f..f260e13242 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -267,6 +267,11 @@ def validate_adc_pin(value): {CONF_ANALOG: True, CONF_INPUT: True}, internal=True )(value) + if CORE.is_nrf52: + return pins.gpio_pin_schema( + {CONF_ANALOG: True, CONF_INPUT: True}, internal=True + )(value) + raise NotImplementedError @@ -283,5 +288,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 00a703191e..526dd57fd5 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -13,6 +13,10 @@ #include "hal/adc_types.h" // This defines ADC_CHANNEL_MAX #endif // USE_ESP32 +#ifdef USE_ZEPHYR +#include +#endif + namespace esphome { namespace adc { @@ -38,15 +42,15 @@ enum class SamplingMode : uint8_t { const LogString *sampling_mode_to_str(SamplingMode mode); -class Aggregator { +template class Aggregator { public: Aggregator(SamplingMode mode); - void add_sample(uint32_t value); - uint32_t aggregate(); + void add_sample(T value); + T aggregate(); protected: - uint32_t aggr_{0}; - uint32_t samples_{0}; + T aggr_{0}; + uint8_t samples_{0}; SamplingMode mode_{SamplingMode::AVG}; }; @@ -69,6 +73,11 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage /// @return A float representing the setup priority. float get_setup_priority() const override; +#ifdef USE_ZEPHYR + /// Set the ADC channel to be used by the ADC sensor. + /// @param channel Pointer to an adc_dt_spec structure representing the ADC channel. + void set_adc_channel(const adc_dt_spec *channel) { this->channel_ = channel; } +#endif /// Set the GPIO pin to be used by the ADC sensor. /// @param pin Pointer to an InternalGPIOPin representing the ADC input pin. void set_pin(InternalGPIOPin *pin) { this->pin_ = pin; } @@ -151,6 +160,10 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage #ifdef USE_RP2040 bool is_temperature_{false}; #endif // USE_RP2040 + +#ifdef USE_ZEPHYR + const struct adc_dt_spec *channel_ = nullptr; +#endif }; } // namespace adc diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 797ab75045..748c8634b7 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -18,15 +18,15 @@ const LogString *sampling_mode_to_str(SamplingMode mode) { return LOG_STR("unknown"); } -Aggregator::Aggregator(SamplingMode mode) { +template Aggregator::Aggregator(SamplingMode mode) { this->mode_ = mode; // set to max uint if mode is "min" if (mode == SamplingMode::MIN) { - this->aggr_ = UINT32_MAX; + this->aggr_ = std::numeric_limits::max(); } } -void Aggregator::add_sample(uint32_t value) { +template void Aggregator::add_sample(T value) { this->samples_ += 1; switch (this->mode_) { @@ -47,7 +47,7 @@ void Aggregator::add_sample(uint32_t value) { } } -uint32_t Aggregator::aggregate() { +template T Aggregator::aggregate() { if (this->mode_ == SamplingMode::AVG) { if (this->samples_ == 0) { return this->aggr_; @@ -59,6 +59,12 @@ uint32_t Aggregator::aggregate() { return this->aggr_; } +#ifdef USE_ZEPHYR +template class Aggregator; +#else +template class Aggregator; +#endif + void ADCSensor::update() { float value_v = this->sample(); ESP_LOGV(TAG, "'%s': Voltage=%.4fV", this->get_name().c_str(), value_v); diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 9905475b1e..87d4ddd35f 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -152,7 +152,7 @@ float ADCSensor::sample() { } float ADCSensor::sample_fixed_attenuation_() { - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { int raw; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index 1b4b314570..be14b252d4 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -37,7 +37,7 @@ void ADCSensor::dump_config() { } float ADCSensor::sample() { - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { uint32_t raw = 0; diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index e4fd4e5d4d..0b1393c2e7 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -30,7 +30,7 @@ void ADCSensor::dump_config() { float ADCSensor::sample() { uint32_t raw = 0; - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); if (this->output_raw_) { for (uint8_t sample = 0; sample < this->sample_count_; sample++) { diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 90c640a0b1..8496e0f41e 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -41,7 +41,7 @@ void ADCSensor::dump_config() { float ADCSensor::sample() { uint32_t raw = 0; - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp new file mode 100644 index 0000000000..2fb9d4b0e5 --- /dev/null +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -0,0 +1,207 @@ + +#include "adc_sensor.h" +#ifdef USE_ZEPHYR +#include "esphome/core/log.h" + +#include "hal/nrf_saadc.h" + +namespace esphome { +namespace adc { + +static const char *const TAG = "adc.zephyr"; + +void ADCSensor::setup() { + if (!adc_is_ready_dt(this->channel_)) { + ESP_LOGE(TAG, "ADC controller device %s not ready", this->channel_->dev->name); + return; + } + + auto err = adc_channel_setup_dt(this->channel_); + if (err < 0) { + ESP_LOGE(TAG, "Could not setup channel %s (%d)", this->channel_->dev->name, err); + return; + } +} + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +static const LogString *gain_to_str(enum adc_gain gain) { + switch (gain) { + case ADC_GAIN_1_6: + return LOG_STR("1/6"); + case ADC_GAIN_1_5: + return LOG_STR("1/5"); + case ADC_GAIN_1_4: + return LOG_STR("1/4"); + case ADC_GAIN_1_3: + return LOG_STR("1/3"); + case ADC_GAIN_2_5: + return LOG_STR("2/5"); + case ADC_GAIN_1_2: + return LOG_STR("1/2"); + case ADC_GAIN_2_3: + return LOG_STR("2/3"); + case ADC_GAIN_4_5: + return LOG_STR("4/5"); + case ADC_GAIN_1: + return LOG_STR("1"); + case ADC_GAIN_2: + return LOG_STR("2"); + case ADC_GAIN_3: + return LOG_STR("3"); + case ADC_GAIN_4: + return LOG_STR("4"); + case ADC_GAIN_6: + return LOG_STR("6"); + case ADC_GAIN_8: + return LOG_STR("8"); + case ADC_GAIN_12: + return LOG_STR("12"); + case ADC_GAIN_16: + return LOG_STR("16"); + case ADC_GAIN_24: + return LOG_STR("24"); + case ADC_GAIN_32: + return LOG_STR("32"); + case ADC_GAIN_64: + return LOG_STR("64"); + case ADC_GAIN_128: + return LOG_STR("128"); + } + return LOG_STR("undefined gain"); +} + +static const LogString *reference_to_str(enum adc_reference reference) { + switch (reference) { + case ADC_REF_VDD_1: + return LOG_STR("VDD"); + case ADC_REF_VDD_1_2: + return LOG_STR("VDD/2"); + case ADC_REF_VDD_1_3: + return LOG_STR("VDD/3"); + case ADC_REF_VDD_1_4: + return LOG_STR("VDD/4"); + case ADC_REF_INTERNAL: + return LOG_STR("INTERNAL"); + case ADC_REF_EXTERNAL0: + return LOG_STR("External, input 0"); + case ADC_REF_EXTERNAL1: + return LOG_STR("External, input 1"); + } + return LOG_STR("undefined reference"); +} + +static const LogString *input_to_str(uint8_t input) { + switch (input) { + case NRF_SAADC_INPUT_AIN0: + return LOG_STR("AIN0"); + case NRF_SAADC_INPUT_AIN1: + return LOG_STR("AIN1"); + case NRF_SAADC_INPUT_AIN2: + return LOG_STR("AIN2"); + case NRF_SAADC_INPUT_AIN3: + return LOG_STR("AIN3"); + case NRF_SAADC_INPUT_AIN4: + return LOG_STR("AIN4"); + case NRF_SAADC_INPUT_AIN5: + return LOG_STR("AIN5"); + case NRF_SAADC_INPUT_AIN6: + return LOG_STR("AIN6"); + case NRF_SAADC_INPUT_AIN7: + return LOG_STR("AIN7"); + case NRF_SAADC_INPUT_VDD: + return LOG_STR("VDD"); + case NRF_SAADC_INPUT_VDDHDIV5: + return LOG_STR("VDDHDIV5"); + } + return LOG_STR("undefined input"); +} +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + +void ADCSensor::dump_config() { + LOG_SENSOR("", "ADC Sensor", this); + LOG_PIN(" Pin: ", this->pin_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, + " Name: %s\n" + " Channel: %d\n" + " vref_mv: %d\n" + " Resolution %d\n" + " Oversampling %d", + this->channel_->dev->name, this->channel_->channel_id, this->channel_->vref_mv, this->channel_->resolution, + this->channel_->oversampling); + + ESP_LOGV(TAG, + " Gain: %s\n" + " reference: %s\n" + " acquisition_time: %d\n" + " differential %s", + LOG_STR_ARG(gain_to_str(this->channel_->channel_cfg.gain)), + LOG_STR_ARG(reference_to_str(this->channel_->channel_cfg.reference)), + this->channel_->channel_cfg.acquisition_time, YESNO(this->channel_->channel_cfg.differential)); + if (this->channel_->channel_cfg.differential) { + ESP_LOGV(TAG, + " Positive: %s\n" + " Negative: %s", + LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_positive)), + LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_negative))); + } else { + ESP_LOGV(TAG, " Positive: %s", LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_positive))); + } +#endif + + LOG_UPDATE_INTERVAL(this); +} + +float ADCSensor::sample() { + auto aggr = Aggregator(this->sampling_mode_); + int err; + for (uint8_t sample = 0; sample < this->sample_count_; sample++) { + int16_t buf = 0; + struct adc_sequence sequence = { + .buffer = &buf, + /* buffer size in bytes, not number of samples */ + .buffer_size = sizeof(buf), + }; + int32_t val_raw; + + err = adc_sequence_init_dt(this->channel_, &sequence); + if (err < 0) { + ESP_LOGE(TAG, "Could sequence init %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + err = adc_read(this->channel_->dev, &sequence); + if (err < 0) { + ESP_LOGE(TAG, "Could not read %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + val_raw = (int32_t) buf; + if (!this->channel_->channel_cfg.differential) { + // https://github.com/adafruit/Adafruit_nRF52_Arduino/blob/0ed4d9ffc674ae407be7cacf5696a02f5e789861/cores/nRF5/wiring_analog_nRF52.c#L222 + if (val_raw < 0) { + val_raw = 0; + } + } + aggr.add_sample(val_raw); + } + + int32_t val_mv = aggr.aggregate(); + + if (this->output_raw_) { + return val_mv; + } + + err = adc_raw_to_millivolts_dt(this->channel_, &val_mv); + /* conversion to mV may not be supported, skip if not */ + if (err < 0) { + ESP_LOGE(TAG, "Value in mV not available %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + return val_mv / 1000.0f; +} + +} // namespace adc +} // namespace esphome +#endif diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 01bbaeda15..49970c5e3d 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -3,6 +3,12 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler from esphome.components.esp32 import get_esp32_variant +from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC +from esphome.components.zephyr import ( + zephyr_add_overlay, + zephyr_add_prj_conf, + zephyr_add_user, +) import esphome.config_validation as cv from esphome.const import ( CONF_ATTENUATION, @@ -11,6 +17,7 @@ from esphome.const import ( CONF_PIN, CONF_RAW, DEVICE_CLASS_VOLTAGE, + PLATFORM_NRF52, STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) @@ -60,6 +67,10 @@ ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) +CONF_NRF_SAADC = "nrf_saadc" + +adc_dt_spec = cg.global_ns.class_("adc_dt_spec") + CONFIG_SCHEMA = cv.All( sensor.sensor_schema( ADCSensor, @@ -75,6 +86,7 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All( cv.only_on_esp32, _attenuation ), + cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec), cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255), cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode, } @@ -83,6 +95,8 @@ CONFIG_SCHEMA = cv.All( validate_config, ) +CONF_ADC_CHANNEL_ID = "adc_channel_id" + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -93,7 +107,7 @@ async def to_code(config): cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": cg.add(var.set_is_temperature()) - else: + elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) @@ -122,3 +136,41 @@ async def to_code(config): ): chan = ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant][pin_num] cg.add(var.set_channel(adc_unit_t.ADC_UNIT_2, chan)) + + elif CORE.is_nrf52: + CORE.data.setdefault(CONF_ADC_CHANNEL_ID, 0) + channel_id = CORE.data[CONF_ADC_CHANNEL_ID] + CORE.data[CONF_ADC_CHANNEL_ID] = channel_id + 1 + zephyr_add_prj_conf("ADC", True) + nrf_saadc = config[CONF_NRF_SAADC] + rhs = cg.RawExpression( + f"ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), {channel_id})" + ) + adc = cg.new_Pvariable(nrf_saadc, rhs) + cg.add(var.set_adc_channel(adc)) + gain = "ADC_GAIN_1_6" + pin_number = config[CONF_PIN][CONF_NUMBER] + if pin_number == "VDDHDIV5": + gain = "ADC_GAIN_1_2" + if isinstance(pin_number, int): + GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} + pin_number = GPIO_TO_AIN[pin_number] + zephyr_add_user("io-channels", f"<&adc {channel_id}>") + zephyr_add_overlay( + f""" +&adc {{ + #address-cells = <1>; + #size-cells = <0>; + + channel@{channel_id} {{ + reg = <{channel_id}>; + zephyr,gain = "{gain}"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <14>; + zephyr,oversampling = <8>; + }}; +}}; +""" + ) diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index d827e5fb22..715d527a66 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,3 +2,17 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" +EXTRA_ADC = [ + "VDD", + "VDDHDIV5", +] +AIN_TO_GPIO = { + "AIN0": 2, + "AIN1": 3, + "AIN2": 4, + "AIN3": 5, + "AIN4": 28, + "AIN5": 29, + "AIN6": 30, + "AIN7": 31, +} diff --git a/esphome/components/nrf52/gpio.py b/esphome/components/nrf52/gpio.py index 85230c1f57..260114f90e 100644 --- a/esphome/components/nrf52/gpio.py +++ b/esphome/components/nrf52/gpio.py @@ -2,12 +2,23 @@ from esphome import pins import esphome.codegen as cg from esphome.components.zephyr.const import zephyr_ns import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER, PLATFORM_NRF52 +from esphome.const import ( + CONF_ANALOG, + CONF_ID, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + PLATFORM_NRF52, +) + +from .const import AIN_TO_GPIO, EXTRA_ADC ZephyrGPIOPin = zephyr_ns.class_("ZephyrGPIOPin", cg.InternalGPIOPin) def _translate_pin(value): + if value in AIN_TO_GPIO: + return AIN_TO_GPIO[value] if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -28,18 +39,33 @@ def _translate_pin(value): def validate_gpio_pin(value): + if value in EXTRA_ADC: + return value value = _translate_pin(value) if value < 0 or value > (32 + 16): raise cv.Invalid(f"NRF52: Invalid pin number: {value}") return value +def validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_analog = mode[CONF_ANALOG] + if is_analog: + if num in EXTRA_ADC: + return value + if num not in AIN_TO_GPIO.values(): + raise cv.Invalid(f"Cannot use {num} as analog pin") + return value + + NRF52_PIN_SCHEMA = cv.All( pins.gpio_base_schema( ZephyrGPIOPin, validate_gpio_pin, - modes=pins.GPIO_STANDARD_MODES, + modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), + validate_supports, ) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 2b542404a5..c698122030 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,5 +1,5 @@ import os -from typing import Final, TypedDict +from typing import TypedDict import esphome.codegen as cg from esphome.const import CONF_BOARD @@ -8,18 +8,19 @@ from esphome.helpers import copy_file_if_changed, write_file_if_changed from .const import ( BOOTLOADER_MCUBOOT, + KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_USER, KEY_ZEPHYR, zephyr_ns, ) CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["preferences"] -KEY_BOARD: Final = "board" PrjConfValueType = bool | str | int @@ -49,6 +50,7 @@ class ZephyrData(TypedDict): overlay: str extra_build_files: dict[str, str] pm_static: list[Section] + user: dict[str, list[str]] def zephyr_set_core_data(config): @@ -59,6 +61,7 @@ def zephyr_set_core_data(config): overlay="", extra_build_files={}, pm_static=[], + user={}, ) return config @@ -178,7 +181,25 @@ def zephyr_add_pm_static(section: Section): CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section) +def zephyr_add_user(key, value): + user = zephyr_data()[KEY_USER] + if key not in user: + user[key] = [] + user[key] += [value] + + def copy_files(): + user = zephyr_data()[KEY_USER] + if user: + zephyr_add_overlay( + f""" +/ {{ + zephyr,user {{ + {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} +}}; +}};""" + ) + want_opts = zephyr_data()[KEY_PRJ_CONF] prj_conf = ( diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f14a326344..06a4fc42bc 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -10,5 +10,7 @@ KEY_OVERLAY: Final = "overlay" KEY_PM_STATIC: Final = "pm_static" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" +KEY_BOARD: Final = "board" +KEY_USER: Final = "user" zephyr_ns = cg.esphome_ns.namespace("zephyr") diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 305ca00c0c..455f4f4b64 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -25,6 +25,7 @@ int main() { return 0;} Path(zephyr_dir / "prj.conf").write_text( """ CONFIG_NEWLIB_LIBC=y +CONFIG_ADC=y """, encoding="utf-8", ) diff --git a/tests/components/adc/test.nrf52-adafruit.yaml b/tests/components/adc/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..4be5b4b5c2 --- /dev/null +++ b/tests/components/adc/test.nrf52-adafruit.yaml @@ -0,0 +1,23 @@ +sensor: + - platform: adc + pin: VDDHDIV5 + name: "VDDH Voltage" + update_interval: 5sec + filters: + - multiply: 5 + - platform: adc + pin: VDD + name: "VDD Voltage" + update_interval: 5sec + - platform: adc + pin: AIN0 + name: "AIN0 Voltage" + update_interval: 5sec + - platform: adc + pin: P0.03 + name: "AIN1 Voltage" + update_interval: 5sec + - platform: adc + name: "AIN2 Voltage" + update_interval: 5sec + pin: 4 diff --git a/tests/components/adc/test.nrf52-mcumgr.yaml b/tests/components/adc/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..4be5b4b5c2 --- /dev/null +++ b/tests/components/adc/test.nrf52-mcumgr.yaml @@ -0,0 +1,23 @@ +sensor: + - platform: adc + pin: VDDHDIV5 + name: "VDDH Voltage" + update_interval: 5sec + filters: + - multiply: 5 + - platform: adc + pin: VDD + name: "VDD Voltage" + update_interval: 5sec + - platform: adc + pin: AIN0 + name: "AIN0 Voltage" + update_interval: 5sec + - platform: adc + pin: P0.03 + name: "AIN1 Voltage" + update_interval: 5sec + - platform: adc + name: "AIN2 Voltage" + update_interval: 5sec + pin: 4 From f13e742bd544691b9c4409f0aef39dd3faba1e47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 16:10:56 -1000 Subject: [PATCH 07/60] [ruff] Enable RET and fix all violations (#9929) --- esphome/automation.py | 6 +-- .../alarm_control_panel/__init__.py | 12 ++---- esphome/components/ble_client/__init__.py | 10 ++--- esphome/components/esp32/__init__.py | 6 +-- esphome/components/esp32/gpio.py | 3 +- .../components/esp32_ble_server/__init__.py | 3 +- esphome/components/haier/climate.py | 15 +++---- esphome/components/light/effects.py | 3 +- esphome/components/lvgl/automation.py | 6 +-- esphome/components/lvgl/lv_validation.py | 3 +- esphome/components/lvgl/styles.py | 3 +- esphome/components/lvgl/widgets/__init__.py | 2 +- .../components/lvgl/widgets/buttonmatrix.py | 2 +- esphome/components/mqtt/__init__.py | 3 +- esphome/components/one_wire/__init__.py | 3 +- esphome/components/packages/__init__.py | 3 +- esphome/components/pmwcs3/sensor.py | 3 +- esphome/components/rf_bridge/__init__.py | 12 ++---- .../components/rp2040_pio_led_strip/light.py | 3 +- esphome/components/sim800l/__init__.py | 6 +-- esphome/components/ufire_ec/sensor.py | 3 +- esphome/components/ufire_ise/sensor.py | 3 +- esphome/config_helpers.py | 3 +- esphome/config_validation.py | 2 +- esphome/cpp_helpers.py | 2 +- esphome/mqtt.py | 3 +- esphome/vscode.py | 3 +- esphome/yaml_util.py | 3 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 43 ++++++++----------- script/build_language_schema.py | 3 +- script/helpers_zephyr.py | 7 ++- tests/dashboard/test_web_server.py | 3 +- tests/integration/conftest.py | 13 +++--- .../loop_test_component/__init__.py | 6 +-- tests/script/test_clang_tidy_hash.py | 2 +- tests/script/test_helpers.py | 5 +-- tests/unit_tests/test_substitutions.py | 5 +-- tests/unit_tests/test_vscode.py | 3 +- 39 files changed, 81 insertions(+), 139 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 34159561c2..99d4362845 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -391,8 +391,7 @@ async def build_action(full_config, template_arg, args): ) action_id = full_config[CONF_TYPE_ID] builder = registry_entry.coroutine_fun - ret = await builder(config, action_id, template_arg, args) - return ret + return await builder(config, action_id, template_arg, args) async def build_action_list(config, templ, arg_type): @@ -409,8 +408,7 @@ async def build_condition(full_config, template_arg, args): ) action_id = full_config[CONF_TYPE_ID] builder = registry_entry.coroutine_fun - ret = await builder(config, action_id, template_arg, args) - return ret + return await builder(config, action_id, template_arg, args) async def build_condition_list(config, templ, args): diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 6d37d53a4c..b076175eb8 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -301,8 +301,7 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args): ) async def alarm_action_pending_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -310,8 +309,7 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args): ) async def alarm_action_trigger_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -319,8 +317,7 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args): ) async def alarm_action_chime_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -333,8 +330,7 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args): ) async def alarm_action_ready_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition( diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index a88172ca87..0f3869c23b 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -175,8 +175,7 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( ) async def ble_disconnect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -184,8 +183,7 @@ async def ble_disconnect_to_code(config, action_id, template_arg, args): ) async def ble_connect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -282,9 +280,7 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): ) async def remove_bond_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - return var + return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b183f10d72..8d72ff3685 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -892,7 +892,7 @@ def get_arduino_partition_csv(flash_size): eeprom_partition_start = app1_partition_start + app_partition_size spiffs_partition_start = eeprom_partition_start + eeprom_partition_size - partition_csv = f"""\ + return f"""\ nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xE000, 0x2000, app0, app, ota_0, 0x{app0_partition_start:X}, 0x{app_partition_size:X}, @@ -900,20 +900,18 @@ app1, app, ota_1, 0x{app1_partition_start:X}, 0x{app_partition_size:X}, eeprom, data, 0x99, 0x{eeprom_partition_start:X}, 0x{eeprom_partition_size:X}, spiffs, data, spiffs, 0x{spiffs_partition_start:X}, 0x{spiffs_partition_size:X} """ - return partition_csv def get_idf_partition_csv(flash_size): app_partition_size = APP_PARTITION_SIZES[flash_size] - partition_csv = f"""\ + return f"""\ otadata, data, ota, , 0x2000, phy_init, data, phy, , 0x1000, app0, app, ota_0, , 0x{app_partition_size:X}, app1, app, ota_1, , 0x{app_partition_size:X}, nvs, data, nvs, , 0x6D000, """ - return partition_csv def _format_sdkconfig_val(value: SdkconfigValueType) -> str: diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index c35e5c2215..513f463d57 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -187,8 +187,7 @@ def validate_supports(value): "Open-drain only works with output mode", [CONF_MODE, CONF_OPEN_DRAIN] ) - value = _esp32_validations[variant].usage_validation(value) - return value + return _esp32_validations[variant].usage_validation(value) # https://docs.espressif.com/projects/esp-idf/en/v3.3.5/api-reference/peripherals/gpio.html#_CPPv416gpio_drive_cap_t diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 19f466eb7b..6f16d76a32 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -628,5 +628,4 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 0393c263d4..8c3649058f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -330,8 +330,7 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) async def display_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -342,8 +341,7 @@ async def display_action_to_code(config, action_id, template_arg, args): ) async def beeper_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) # Start self cleaning or steri-cleaning action action @@ -359,8 +357,7 @@ async def beeper_action_to_code(config, action_id, template_arg, args): ) async def start_cleaning_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) # Set vertical airflow direction action @@ -417,8 +414,7 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, ) async def health_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -432,8 +428,7 @@ async def health_action_to_code(config, action_id, template_arg, args): ) async def power_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config): diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index f5749a17ab..6c8fd86225 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -353,10 +353,9 @@ async def addressable_lambda_effect_to_code(config, effect_id): (bool, "initial_run"), ] lambda_ = await cg.process_lambda(config[CONF_LAMBDA], args, return_type=cg.void) - var = cg.new_Pvariable( + return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] ) - return var @register_addressable_effect( diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index cc0f833ced..fc70b0f682 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -85,8 +85,7 @@ async def action_to_code( async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def update_to_code(config, action_id, template_arg, args): @@ -354,8 +353,7 @@ async def widget_focus(config, action_id, template_arg, args): if config[CONF_FREEZE]: lv.group_focus_freeze(group, True) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @automation.register_action( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 92fe74eb52..5a1b99cf7c 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -271,8 +271,7 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def zoom_validator(value): - value = cv.float_range(0.1, 10.0)(value) - return value + return cv.float_range(0.1, 10.0)(value) def zoom_retmapper(value): diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 11d7bca5fa..3969c9f388 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -66,8 +66,7 @@ async def style_update_to_code(config, action_id, template_arg, args): async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def theme_to_code(config): diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index a8cb8dce33..d12464fe71 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -189,7 +189,7 @@ class Widget: for matrix buttons :return: """ - return None + return def get_max(self): return self.type.get_max(self.config) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index aa33be722c..c6b6d2440f 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -193,7 +193,7 @@ class ButtonMatrixType(WidgetType): async def to_code(self, w: Widget, config): lvgl_components_required.add("BUTTONMATRIX") if CONF_ROWS not in config: - return [] + return text_list, ctrl_list, width_list, key_list = await get_button_data( config[CONF_ROWS], w ) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 1a6fcabf42..52d3181780 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -312,14 +312,13 @@ CONFIG_SCHEMA = cv.All( def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) - exp = cg.StructInitializer( + return cg.StructInitializer( MQTTMessage, ("topic", config[CONF_TOPIC]), ("payload", config.get(CONF_PAYLOAD, "")), ("qos", config[CONF_QOS]), ("retain", config[CONF_RETAIN]), ) - return exp @coroutine_with_priority(40.0) diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 99a1ccd1eb..6d95b8fd33 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -18,13 +18,12 @@ def one_wire_device_schema(): :return: The 1-wire device schema, `extend` this in your config schema. """ - schema = cv.Schema( + return cv.Schema( { cv.GenerateID(CONF_ONE_WIRE_ID): cv.use_id(OneWireBus), cv.Optional(CONF_ADDRESS): cv.hex_uint64_t, } ) - return schema async def register_one_wire_device(var, config): diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 0db7841db2..2e7dc0e197 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -186,8 +186,7 @@ def _process_package(package_config, config): package_config = _process_base_package(package_config) if isinstance(package_config, dict): recursive_package = do_packages_pass(package_config) - config = merge_config(recursive_package, config) - return config + return merge_config(recursive_package, config) def do_packages_pass(config: dict): diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index d42338ab6f..075b9b00b5 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -114,8 +114,7 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( ) async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 5ccca823de..b4770726b4 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -136,8 +136,7 @@ RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) @automation.register_action("rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA) async def rf_bridge_learnx_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -149,8 +148,7 @@ async def rf_bridge_start_advanced_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -162,8 +160,7 @@ async def rf_bridge_stop_advanced_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -175,8 +172,7 @@ async def rf_bridge_start_bucket_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 9107db9b7f..62f7fffdc9 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -125,8 +125,7 @@ writezero: def time_to_cycles(time_us): cycles_per_us = 57.5 - cycles = round(float(time_us) * cycles_per_us) - return cycles + return round(float(time_us) * cycles_per_us) CONF_PIO = "pio" diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 2ca9127d3f..c48a3c63c4 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -171,8 +171,7 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): ) async def sim800l_connect_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) SIM800L_SEND_USSD_SCHEMA = cv.Schema( @@ -201,5 +200,4 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): ) async def sim800l_disconnect_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 944fdfdee9..9edf0f89ff 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -122,5 +122,4 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) async def ufire_ec_reset_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index e57a1155a4..8009cdaa6a 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -123,5 +123,4 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent ) async def ufire_ise_reset_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) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index 50ce4e8e34..00cd8f9818 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -111,8 +111,7 @@ def merge_config(full_old, full_new): else: ids[new_id] = len(res) res.append(v) - res = [v for i, v in enumerate(res) if i not in ids_to_delete] - return res + return [v for i, v in enumerate(res) if i not in ids_to_delete] if new is None: return old diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a79f8cd17c..84ffd9941e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1868,7 +1868,7 @@ def validate_registry_entry(name, registry): def none(value): if value in ("none", "None"): - return None + return raise Invalid("Must be none") diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 3f64be6154..b61b215bdc 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -115,7 +115,7 @@ async def build_registry_list(registry, config): async def past_safe_mode(): if CONF_SAFE_MODE not in CORE.config: - return + return None def _safe_mode_generator(): while True: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index acfa8a0926..f1c631697a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) def config_from_env(): - config = { + return { CONF_MQTT: { CONF_USERNAME: get_str_env("ESPHOME_DASHBOARD_MQTT_USERNAME"), CONF_PASSWORD: get_str_env("ESPHOME_DASHBOARD_MQTT_PASSWORD"), @@ -44,7 +44,6 @@ def config_from_env(): CONF_PORT: get_int_env("ESPHOME_DASHBOARD_MQTT_PORT", 1883), }, } - return config def initialize( diff --git a/esphome/vscode.py b/esphome/vscode.py index d8cfe91938..f5e2a20b97 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -81,8 +81,7 @@ def _print_file_read_event(path: str) -> None: def _request_and_get_stream_on_stdin(fname: str) -> StringIO: _print_file_read_event(fname) - raw_yaml_stream = StringIO(_read_file_content_from_json_on_stdin()) - return raw_yaml_stream + return StringIO(_read_file_content_from_json_on_stdin()) def _vscode_loader(fname: str) -> dict[str, Any]: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 33a56fc158..f26bc0502d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -305,8 +305,7 @@ class ESPHomeLoaderMixin: result = self.yaml_loader(self._rel_path(file)) if not vars: vars = {} - result = substitute_vars(result, vars) - return result + return substitute_vars(result, vars) @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: diff --git a/pyproject.toml b/pyproject.toml index 200f51a873..4943c48eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ select = [ "PERF", # performance "PL", # pylint "SIM", # flake8-simplify + "RET", # flake8-ret "UP", # pyupgrade ] diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 03f8d0f8bc..24e2b25e90 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -539,8 +539,7 @@ class BoolType(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f"out.append(YESNO({name}));" - return o + return f"out.append(YESNO({name}));" def get_size_calculation(self, name: str, force: bool = False) -> str: return self._get_simple_size_calculation(name, force, "add_bool") @@ -592,9 +591,8 @@ class StringType(TypeInfo): if no_zero_copy: # Use the std::string directly return f"buffer.encode_string({self.number}, this->{self.field_name});" - else: - # Use the StringRef - return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" + # Use the StringRef + return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" def dump(self, name): # Check if no_zero_copy option is set @@ -716,8 +714,7 @@ class MessageType(TypeInfo): return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" def dump(self, name: str) -> str: - o = f"{name}.dump_to(out);" - return o + return f"{name}.dump_to(out);" @property def dump_content(self) -> str: @@ -865,8 +862,7 @@ class FixedArrayBytesType(TypeInfo): return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" def dump(self, name: str) -> str: - o = f"out.append(format_hex_pretty({name}, {name}_len));" - return o + return f"out.append(format_hex_pretty({name}, {name}_len));" @property def dump_content(self) -> str: @@ -883,9 +879,8 @@ class FixedArrayBytesType(TypeInfo): if force: # For repeated fields, always calculate size (no zero check) return f"size.add_length_force({field_id_size}, {length_field});" - else: - # For non-repeated fields, add_length already checks for zero - return f"size.add_length({field_id_size}, {length_field});" + # For non-repeated fields, add_length already checks for zero + return f"size.add_length({field_id_size}, {length_field});" def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -940,8 +935,7 @@ class EnumType(TypeInfo): return f"buffer.{self.encode_func}({self.number}, static_cast(this->{self.field_name}));" def dump(self, name: str) -> str: - o = f"out.append(proto_enum_to_string<{self.cpp_type}>({name}));" - return o + return f"out.append(proto_enum_to_string<{self.cpp_type}>({name}));" def dump_field_value(self, value: str) -> str: # Enums need explicit cast for the template @@ -1115,8 +1109,7 @@ class FixedArrayRepeatedType(TypeInfo): def encode_element(element: str) -> str: if isinstance(self._ti, EnumType): return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" - else: - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: @@ -1133,7 +1126,7 @@ class FixedArrayRepeatedType(TypeInfo): # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") - elif self.array_size == 2: + if self.array_size == 2: return ( encode_element(f"this->{self.field_name}[0]") + "\n " @@ -1224,8 +1217,7 @@ class RepeatedTypeInfo(TypeInfo): # use it as-is, otherwise append the element type if "<" in self._container_type and ">" in self._container_type: return f"const {self._container_type}*" - else: - return f"const {self._container_type}<{self._ti.cpp_type}>*" + return f"const {self._container_type}<{self._ti.cpp_type}>*" return f"std::vector<{self._ti.cpp_type}>" @property @@ -1311,14 +1303,13 @@ class RepeatedTypeInfo(TypeInfo): o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" o += "}" return o + o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" else: - o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" - else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" - o += "}" - return o + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += "}" + return o @property def dump_content(self) -> str: diff --git a/script/build_language_schema.py b/script/build_language_schema.py index c114d15315..ff6e898902 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -444,8 +444,7 @@ def get_str_path_schema(strPath): if len(parts) > 2: parts[0] += "." + parts[1] parts[1] = parts[2] - s1 = output.get(parts[0], {}).get(S_SCHEMAS, {}).get(parts[1], {}) - return s1 + return output.get(parts[0], {}).get(S_SCHEMAS, {}).get(parts[1], {}) def pop_str_path_schema(strPath): diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 455f4f4b64..922f1171b4 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -43,12 +43,11 @@ CONFIG_ADC=y def extract_defines(command): define_pattern = re.compile(r"-D\s*([^\s]+)") - defines = [ + return [ match for match in define_pattern.findall(command) if match not in ("_ASMLANGUAGE") ] - return defines def find_cxx_path(commands): for entry in commands: @@ -57,6 +56,7 @@ CONFIG_ADC=y if not cxx_path.endswith("++"): continue return cxx_path + return None def get_builtin_include_paths(compiler): result = subprocess.run( @@ -84,11 +84,10 @@ CONFIG_ADC=y flag_pattern = re.compile( r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" ) - flags = [ + return [ match.replace("-imacros ", "-imacros") for match in flag_pattern.findall(command) ] - return flags def transform_to_idedata_format(compile_commands): cxx_path = find_cxx_path(compile_commands) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index cd02200d0b..b77ab7a7a3 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -31,8 +31,7 @@ class DashboardTestHelper: else: url = f"http://127.0.0.1:{self.port}{path}" future = self.client.fetch(url, raise_error=True, **kwargs) - result = await future - return result + return await future @pytest_asyncio.fixture() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 46eb6c88e2..55bf0b97a7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -251,19 +251,18 @@ async def compile_esphome( if proc.returncode == 0: # Success! break - elif proc.returncode == -11 and attempt < max_retries - 1: + if proc.returncode == -11 and attempt < max_retries - 1: # Segfault (-11 = SIGSEGV), retry print( f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." ) await asyncio.sleep(1) # Brief pause before retry continue - else: - # Other error or final retry - raise RuntimeError( - f"Failed to compile {config_path}, return code: {proc.returncode}. " - f"Run with 'pytest -s' to see compilation output." - ) + # Other error or final retry + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) # Load the config to get idedata (blocking call, must use executor) loop = asyncio.get_running_loop() diff --git a/tests/integration/fixtures/external_components/loop_test_component/__init__.py b/tests/integration/fixtures/external_components/loop_test_component/__init__.py index 3f3a40db09..a0b0f8c65a 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/__init__.py +++ b/tests/integration/fixtures/external_components/loop_test_component/__init__.py @@ -72,8 +72,7 @@ DisableAction = loop_test_component_ns.class_("DisableAction", automation.Action ) async def enable_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -87,8 +86,7 @@ async def enable_to_code(config, action_id, template_arg, args): ) async def disable_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 7b66a69adb..2f84d11a0d 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -69,7 +69,7 @@ def test_calculate_clang_tidy_hash() -> None: def read_file_mock(path: Path) -> bytes: if ".clang-tidy" in str(path): return clang_tidy_content - elif "platformio.ini" in str(path): + if "platformio.ini" in str(path): return platformio_content return b"" diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 423e2d3c30..9730efd366 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -315,9 +315,8 @@ def test_local_development_no_remotes_configured(monkeypatch: MonkeyPatch) -> No def side_effect_func(*args): if args == ("git", "remote"): return "origin\nupstream\n" - else: - # All merge-base attempts fail - raise Exception("Command failed") + # All merge-base attempts fail + raise Exception("Command failed") mock_output.side_effect = side_effect_func diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b65fecb26e..b2b7cb1ea4 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -18,11 +18,10 @@ def sort_dicts(obj): """Recursively sort dictionaries for order-insensitive comparison.""" if isinstance(obj, dict): return {k: sort_dicts(obj[k]) for k in sorted(obj)} - elif isinstance(obj, list): + if isinstance(obj, list): # Lists are not sorted; we preserve order return [sort_dicts(i) for i in obj] - else: - return obj + return obj def dict_diff(a, b, path=""): diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 6e0bde23b2..4b28a2215b 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -22,8 +22,7 @@ def _run_repl_test(input_data): call[0][0] for call in mock_stdout.write.call_args_list ).strip() splitted_output = full_output.split("\n") - remove_version = splitted_output[1:] # remove first entry with version info - return remove_version + return splitted_output[1:] # remove first entry with version info def _validate(file_path: str): From 0954a6185cc68fc5ff36e167ec98c2d65e5c28d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:15:56 -0400 Subject: [PATCH 08/60] [sensor] Fix bug in percentage based delta filter (#8157) Co-authored-by: J. Nick Koston --- esphome/components/sensor/filter.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 39b507f960..7107f12462 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -375,13 +375,11 @@ optional DeltaFilter::new_value(float value) { if (std::isnan(this->last_value_)) { return {}; } else { - if (this->percentage_mode_) { - this->current_delta_ = fabsf(value * this->delta_); - } return this->last_value_ = value; } } - if (std::isnan(this->last_value_) || fabsf(value - this->last_value_) >= this->current_delta_) { + float diff = fabsf(value - this->last_value_); + if (std::isnan(this->last_value_) || (diff > 0.0f && diff >= this->current_delta_)) { if (this->percentage_mode_) { this->current_delta_ = fabsf(value * this->delta_); } From 291215909aec990d70f6f38afa5069cd4ca4591f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 31 Jul 2025 21:55:59 -0500 Subject: [PATCH 09/60] [sensor] A little bit of filter clean-up (#9986) --- esphome/components/sensor/__init__.py | 6 +++++- esphome/components/sensor/filter.cpp | 6 +++--- esphome/components/sensor/filter.h | 10 +++++----- tests/components/template/common.yaml | 7 +++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5d70785389..23e6ad0f2c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -599,7 +599,9 @@ async def throttle_filter_to_code(config, filter_id): TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( { cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, - cv.Optional(CONF_VALUE, default="nan"): cv.ensure_list(cv.float_), + cv.Optional(CONF_VALUE, default="nan"): cv.Any( + cv.templatable(cv.float_), [cv.templatable(cv.float_)] + ), }, key=CONF_TIMEOUT, ) @@ -611,6 +613,8 @@ TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( TIMEOUT_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): + if not isinstance(config[CONF_VALUE], list): + config[CONF_VALUE] = [config[CONF_VALUE]] template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 7107f12462..f077ad2416 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -225,7 +225,7 @@ optional SlidingWindowMovingAverageFilter::new_value(float value) { // ExponentialMovingAverageFilter ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at) - : send_every_(send_every), send_at_(send_every - send_first_at), alpha_(alpha) {} + : alpha_(alpha), send_every_(send_every), send_at_(send_every - send_first_at) {} optional ExponentialMovingAverageFilter::new_value(float value) { if (!std::isnan(value)) { if (this->first_value_) { @@ -325,7 +325,7 @@ optional FilterOutValueFilter::new_value(float value) { // ThrottleFilter ThrottleFilter::ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleFilter::new_value(float value) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_) { this->last_input_ = now; return value; @@ -369,7 +369,7 @@ optional ThrottleWithPriorityFilter::new_value(float value) { // DeltaFilter DeltaFilter::DeltaFilter(float delta, bool percentage_mode) - : delta_(delta), current_delta_(delta), percentage_mode_(percentage_mode), last_value_(NAN) {} + : delta_(delta), current_delta_(delta), last_value_(NAN), percentage_mode_(percentage_mode) {} optional DeltaFilter::new_value(float value) { if (std::isnan(value)) { if (std::isnan(this->last_value_)) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 8e2c6fef08..5765c9a081 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -221,11 +221,11 @@ class ExponentialMovingAverageFilter : public Filter { void set_alpha(float alpha); protected: - bool first_value_{true}; float accumulator_{NAN}; + float alpha_; size_t send_every_; size_t send_at_; - float alpha_; + bool first_value_{true}; }; /** Simple throttle average filter. @@ -243,9 +243,9 @@ class ThrottleAverageFilter : public Filter, public Component { float get_setup_priority() const override; protected: - uint32_t time_period_; float sum_{0.0f}; unsigned int n_{0}; + uint32_t time_period_; bool have_nan_{false}; }; @@ -378,8 +378,8 @@ class DeltaFilter : public Filter { protected: float delta_; float current_delta_; - bool percentage_mode_; float last_value_{NAN}; + bool percentage_mode_; }; class OrFilter : public Filter { @@ -401,8 +401,8 @@ class OrFilter : public Filter { }; std::vector filters_; - bool has_value_{false}; PhiNode phi_; + bool has_value_{false}; }; class CalibrateLinearFilter : public Filter { diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index e185e01c5e..6b7c7ddea1 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -135,10 +135,17 @@ sensor: - throttle: 1s - throttle_average: 2s - throttle_with_priority: 5s + - throttle_with_priority: + timeout: 3s + value: 42.0 + - throttle_with_priority: + timeout: 3s + value: !lambda return 1.0f / 2.0f; - throttle_with_priority: timeout: 3s value: - 42.0 + - !lambda return 2.0f / 2.0f; - nan - timeout: timeout: 10s From c42c5dd946d75fc5120577d6ea02dd1fcc70650e Mon Sep 17 00:00:00 2001 From: NP v/d Spek Date: Fri, 1 Aug 2025 06:51:01 +0200 Subject: [PATCH 10/60] [espnow] Basic communication between ESP32 devices (#9582) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/espnow/__init__.py | 320 ++++++++++++ esphome/components/espnow/automation.h | 175 +++++++ .../components/espnow/espnow_component.cpp | 468 ++++++++++++++++++ esphome/components/espnow/espnow_component.h | 182 +++++++ esphome/components/espnow/espnow_err.h | 19 + esphome/components/espnow/espnow_packet.h | 166 +++++++ tests/components/espnow/common.yaml | 52 ++ tests/components/espnow/test.esp32-idf.yaml | 1 + 9 files changed, 1384 insertions(+) create mode 100644 esphome/components/espnow/__init__.py create mode 100644 esphome/components/espnow/automation.h create mode 100644 esphome/components/espnow/espnow_component.cpp create mode 100644 esphome/components/espnow/espnow_component.h create mode 100644 esphome/components/espnow/espnow_err.h create mode 100644 esphome/components/espnow/espnow_packet.h create mode 100644 tests/components/espnow/common.yaml create mode 100644 tests/components/espnow/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 244e204ab6..e40be9a737 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -155,6 +155,7 @@ esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core esphome/components/esp_ldo/* @clydebarrow +esphome/components/espnow/* @jesserockz esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat esphome/components/event_emitter/* @Rapsssito diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py new file mode 100644 index 0000000000..d15817cf92 --- /dev/null +++ b/esphome/components/espnow/__init__.py @@ -0,0 +1,320 @@ +from esphome import automation, core +import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.udp import CONF_ON_RECEIVE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_CHANNEL, + CONF_DATA, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_ON_ERROR, + CONF_TRIGGER_ID, + CONF_WIFI, +) +from esphome.core import CORE, HexInt +from esphome.types import ConfigType + +CODEOWNERS = ["@jesserockz"] + +byte_vector = cg.std_vector.template(cg.uint8) +peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) + +espnow_ns = cg.esphome_ns.namespace("espnow") +ESPNowComponent = espnow_ns.class_("ESPNowComponent", cg.Component) + +# Handler interfaces that other components can use to register callbacks +ESPNowReceivedPacketHandler = espnow_ns.class_("ESPNowReceivedPacketHandler") +ESPNowUnknownPeerHandler = espnow_ns.class_("ESPNowUnknownPeerHandler") +ESPNowBroadcastedHandler = espnow_ns.class_("ESPNowBroadcastedHandler") + +ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") +ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") + +SendAction = espnow_ns.class_("SendAction", automation.Action) +SetChannelAction = espnow_ns.class_("SetChannelAction", automation.Action) +AddPeerAction = espnow_ns.class_("AddPeerAction", automation.Action) +DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) + +ESPNowHandlerTrigger = automation.Trigger.template( + ESPNowRecvInfoConstRef, + cg.uint8.operator("const").operator("ptr"), + cg.uint8, +) + +OnUnknownPeerTrigger = espnow_ns.class_( + "OnUnknownPeerTrigger", ESPNowHandlerTrigger, ESPNowUnknownPeerHandler +) +OnReceiveTrigger = espnow_ns.class_( + "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivedPacketHandler +) +OnBroadcastedTrigger = espnow_ns.class_( + "OnBroadcastedTrigger", ESPNowHandlerTrigger, ESPNowBroadcastedHandler +) + + +CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_PEERS = "peers" +CONF_ON_SENT = "on_sent" +CONF_ON_UNKNOWN_PEER = "on_unknown_peer" +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_unknown_peer(config): + if config[CONF_AUTO_ADD_PEER] and config.get(CONF_ON_UNKNOWN_PEER): + raise cv.Invalid( + f"'{CONF_ON_UNKNOWN_PEER}' cannot be used when '{CONF_AUTO_ADD_PEER}' is enabled.", + path=[CONF_ON_UNKNOWN_PEER], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESPNowComponent), + cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): wifi.validate_channel, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + 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( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnUnknownPeerTrigger), + }, + single=True, + ), + cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnReceiveTrigger), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), + cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastedTrigger), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), + }, + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _validate_unknown_peer, +) + + +async def _trigger_to_code(config): + if address := config.get(CONF_ADDRESS): + address = address.parts + trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) + await automation.build_automation( + trigger, + [ + (ESPNowRecvInfoConstRef, "info"), + (cg.uint8.operator("const").operator("ptr"), "data"), + (cg.uint8, "size"), + ], + config, + ) + return trigger + + +async def to_code(config): + print(config) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CORE.using_arduino: + cg.add_library("WiFi", None) + + cg.add_define("USE_ESPNOW") + if wifi_channel := config.get(CONF_CHANNEL): + cg.add(var.set_wifi_channel(wifi_channel)) + + cg.add(var.set_auto_add_peer(config[CONF_AUTO_ADD_PEER])) + + for peer in config.get(CONF_PEERS, []): + cg.add(var.add_peer(peer.parts)) + + if on_receive := config.get(CONF_ON_UNKNOWN_PEER): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_unknown_peer_handler(trigger)) + + for on_receive in config.get(CONF_ON_RECEIVE, []): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_received_handler(trigger)) + + for on_receive in config.get(CONF_ON_BROADCAST, []): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_broadcasted_handler(trigger)) + + +# ========================================== A C T I O N S ================================================ + + +def validate_peer(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + return cv.mac_address(value) + + +def _validate_raw_data(value): + if isinstance(value, str): + if len(value) >= MAX_ESPNOW_PACKET_SIZE: + raise cv.Invalid( + f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" + ) + return value + if isinstance(value, list): + if len(value) > MAX_ESPNOW_PACKET_SIZE: + raise cv.Invalid( + f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" + ) + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + f"'{CONF_DATA}' must either be a string wrapped in quotes or a list of bytes" + ) + + +async def register_peer(var, config, args): + peer = config[CONF_ADDRESS] + if isinstance(peer, core.MACAddress): + peer = [HexInt(p) for p in peer.parts] + + template_ = await cg.templatable(peer, args, peer_address_t, peer_address_t) + cg.add(var.set_address(template_)) + + +PEER_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(ESPNowComponent), + cv.Required(CONF_ADDRESS): cv.templatable(cv.mac_address), + } +) + +SEND_SCHEMA = PEER_SCHEMA.extend( + { + cv.Required(CONF_DATA): cv.templatable(_validate_raw_data), + cv.Optional(CONF_ON_SENT): automation.validate_action_list, + cv.Optional(CONF_ON_ERROR): automation.validate_action_list, + cv.Optional(CONF_WAIT_FOR_SENT, default=True): cv.boolean, + cv.Optional(CONF_CONTINUE_ON_ERROR, default=True): cv.boolean, + } +) + + +def _validate_send_action(config): + if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: + raise cv.Invalid( + f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", + path=[CONF_CONTINUE_ON_ERROR], + ) + return config + + +SEND_SCHEMA.add_extra(_validate_send_action) + + +@automation.register_action( + "espnow.send", + SendAction, + SEND_SCHEMA, +) +@automation.register_action( + "espnow.broadcast", + SendAction, + cv.maybe_simple_value( + SEND_SCHEMA.extend( + { + cv.Optional(CONF_ADDRESS, default="FF:FF:FF:FF:FF:FF"): cv.mac_address, + } + ), + key=CONF_DATA, + ), +) +async def send_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + await register_peer(var, config, args) + + data = config.get(CONF_DATA, []) + if isinstance(data, str): + data = [cg.RawExpression(f"'{c}'") for c in data] + templ = await cg.templatable(data, args, byte_vector, byte_vector) + cg.add(var.set_data(templ)) + + cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT])) + cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR])) + + if on_sent_config := config.get(CONF_ON_SENT): + actions = await automation.build_action_list(on_sent_config, template_arg, args) + cg.add(var.add_on_sent(actions)) + if on_error_config := config.get(CONF_ON_ERROR): + actions = await automation.build_action_list( + on_error_config, template_arg, args + ) + cg.add(var.add_on_error(actions)) + return var + + +@automation.register_action( + "espnow.peer.add", + AddPeerAction, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), +) +@automation.register_action( + "espnow.peer.delete", + DeletePeerAction, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), +) +async def peer_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + await register_peer(var, config, args) + + return var + + +@automation.register_action( + "espnow.set_channel", + SetChannelAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(ESPNowComponent), + cv.Required(CONF_CHANNEL): cv.templatable(wifi.validate_channel), + }, + key=CONF_CHANNEL, + ), +) +async def channel_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) + cg.add(var.set_channel(template_)) + return var diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h new file mode 100644 index 0000000000..ad534b279a --- /dev/null +++ b/esphome/components/espnow/automation.h @@ -0,0 +1,175 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::espnow { + +template class SendAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + TEMPLATABLE_VALUE(std::vector, data); + + public: + void add_on_sent(const std::vector *> &actions) { + this->sent_.add_actions(actions); + if (this->flags_.wait_for_sent) { + this->sent_.add_action(new LambdaAction([this](Ts... x) { this->play_next_(x...); })); + } + } + void add_on_error(const std::vector *> &actions) { + this->error_.add_actions(actions); + if (this->flags_.wait_for_sent) { + this->error_.add_action(new LambdaAction([this](Ts... x) { + if (this->flags_.continue_on_error) { + this->play_next_(x...); + } else { + this->stop_complex(); + } + })); + } + } + + void set_wait_for_sent(bool wait_for_sent) { this->flags_.wait_for_sent = wait_for_sent; } + void set_continue_on_error(bool continue_on_error) { this->flags_.continue_on_error = continue_on_error; } + + void play_complex(Ts... x) override { + this->num_running_++; + send_callback_t send_callback = [this, x...](esp_err_t status) { + if (status == ESP_OK) { + if (this->sent_.empty() && this->flags_.wait_for_sent) { + this->play_next_(x...); + } else if (!this->sent_.empty()) { + this->sent_.play(x...); + } + } else { + if (this->error_.empty() && this->flags_.wait_for_sent) { + if (this->flags_.continue_on_error) { + this->play_next_(x...); + } else { + this->stop_complex(); + } + } else if (!this->error_.empty()) { + this->error_.play(x...); + } + } + }; + peer_address_t address = this->address_.value(x...); + std::vector data = this->data_.value(x...); + esp_err_t err = this->parent_->send(address.data(), data, send_callback); + if (err != ESP_OK) { + send_callback(err); + } else if (!this->flags_.wait_for_sent) { + this->play_next_(x...); + } + } + + void play(Ts... x) override { /* ignore - see play_complex */ + } + + void stop() override { + this->sent_.stop(); + this->error_.stop(); + } + + protected: + ActionList sent_; + ActionList error_; + + struct { + uint8_t wait_for_sent : 1; // Wait for the send operation to complete before continuing automation + uint8_t continue_on_error : 1; // Continue automation even if the send operation fails + uint8_t reserved : 6; // Reserved for future use + } flags_{0}; +}; + +template class AddPeerAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + + public: + void play(Ts... x) override { + peer_address_t address = this->address_.value(x...); + this->parent_->add_peer(address.data()); + } +}; + +template class DeletePeerAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + + public: + void play(Ts... x) override { + peer_address_t address = this->address_.value(x...); + this->parent_->del_peer(address.data()); + } +}; + +template class SetChannelAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, channel) + void play(Ts... x) override { + if (this->parent_->is_wifi_enabled()) { + return; + } + this->parent_->set_wifi_channel(this->channel_.value(x...)); + this->parent_->apply_wifi_channel(); + } +}; + +class OnReceiveTrigger : public Trigger, + public ESPNowReceivedPacketHandler { + public: + explicit OnReceiveTrigger(std::array address) : has_address_(true) { + memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); + } + + explicit OnReceiveTrigger() : has_address_(false) {} + + bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); + if (!match) + return false; + + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } + + protected: + bool has_address_{false}; + const uint8_t *address_[ESP_NOW_ETH_ALEN]; +}; +class OnUnknownPeerTrigger : 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 OnBroadcastedTrigger : public Trigger, + public ESPNowBroadcastedHandler { + public: + explicit OnBroadcastedTrigger(std::array address) : has_address_(true) { + memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); + } + explicit OnBroadcastedTrigger() : has_address_(false) {} + + bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); + if (!match) + return false; + + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } + + protected: + bool has_address_{false}; + const uint8_t *address_[ESP_NOW_ETH_ALEN]; +}; + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp new file mode 100644 index 0000000000..dab8e2b726 --- /dev/null +++ b/esphome/components/espnow/espnow_component.cpp @@ -0,0 +1,468 @@ +#include "espnow_component.h" + +#ifdef USE_ESP32 + +#include "espnow_err.h" + +#include "esphome/core/defines.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +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) { + switch (error) { + case ESP_ERR_ESPNOW_FAILED: + return LOG_STR("ESPNow is in fail mode"); + case ESP_ERR_ESPNOW_OWN_ADDRESS: + return LOG_STR("Message to your self"); + case ESP_ERR_ESPNOW_DATA_SIZE: + return LOG_STR("Data size to large"); + case ESP_ERR_ESPNOW_PEER_NOT_SET: + return LOG_STR("Peer address not set"); + case ESP_ERR_ESPNOW_PEER_NOT_PAIRED: + return LOG_STR("Peer address not paired"); + case ESP_ERR_ESPNOW_NOT_INIT: + return LOG_STR("Not init"); + case ESP_ERR_ESPNOW_ARG: + return LOG_STR("Invalid argument"); + case ESP_ERR_ESPNOW_INTERNAL: + return LOG_STR("Internal Error"); + case ESP_ERR_ESPNOW_NO_MEM: + return LOG_STR("Our of memory"); + case ESP_ERR_ESPNOW_NOT_FOUND: + return LOG_STR("Peer not found"); + case ESP_ERR_ESPNOW_IF: + return LOG_STR("Interface does not match"); + case ESP_OK: + return LOG_STR("OK"); + case ESP_NOW_SEND_FAIL: + return LOG_STR("Failed"); + default: + return LOG_STR("Unknown Error"); + } +} + +std::string peer_str(uint8_t *peer) { + if (peer == nullptr || peer[0] == 0) { + return "[Not Set]"; + } else if (memcmp(peer, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + return "[Broadcast]"; + } else if (memcmp(peer, ESPNOW_MULTICAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + return "[Multicast]"; + } else { + return format_mac_address_pretty(peer); + } +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) +#else +void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) +#endif +{ + // Allocate an event from the pool + ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + if (packet == nullptr) { + // No events available - queue is full or we're out of memory + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + +// Load new packet data (replaces previous packet) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + packet->load_sent_data(info->des_addr, status); +#else + packet->load_sent_data(mac_addr, status); +#endif + + // Push the packet to the queue + global_esp_now->receive_packet_queue_.push(packet); + // Push always because we're the only producer and the pool ensures we never exceed queue size +} + +void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Allocate an event from the pool + ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + if (packet == nullptr) { + // No events available - queue is full or we're out of memory + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + + // Load new packet data (replaces previous packet) + packet->load_received_data(info, data, size); + + // Push the packet to the queue + global_esp_now->receive_packet_queue_.push(packet); + // Push always because we're the only producer and the pool ensures we never exceed queue size +} + +ESPNowComponent::ESPNowComponent() { global_esp_now = this; } + +void ESPNowComponent::dump_config() { + uint32_t version = 0; + esp_now_get_version(&version); + + ESP_LOGCONFIG(TAG, "espnow:"); + if (this->is_disabled()) { + ESP_LOGCONFIG(TAG, " Disabled"); + return; + } + ESP_LOGCONFIG(TAG, + " Own address: %s\n" + " Version: v%" PRIu32 "\n" + " Wi-Fi channel: %d", + format_mac_address_pretty(this->own_address_).c_str(), version, this->wifi_channel_); +#ifdef USE_WIFI + ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); +#endif +} + +bool ESPNowComponent::is_wifi_enabled() { +#ifdef USE_WIFI + return wifi::global_wifi_component != nullptr && !wifi::global_wifi_component->is_disabled(); +#else + return false; +#endif +} + +void ESPNowComponent::setup() { + if (this->enable_on_boot_) { + this->enable_(); + } else { + this->state_ = ESPNOW_STATE_DISABLED; + } +} + +void ESPNowComponent::enable() { + if (this->state_ != ESPNOW_STATE_ENABLED) + return; + + ESP_LOGD(TAG, "Enabling"); + this->state_ = ESPNOW_STATE_OFF; + + this->enable_(); +} + +void ESPNowComponent::enable_() { + if (!this->is_wifi_enabled()) { + esp_event_loop_create_default(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); + + this->apply_wifi_channel(); + } +#ifdef USE_WIFI + else { + this->wifi_channel_ = wifi::global_wifi_component->get_wifi_channel(); + } +#endif + + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = esp_now_register_recv_cb(on_data_received); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = esp_now_register_send_cb(on_send_report); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + 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 + + for (auto peer : this->peers_) { + this->add_peer(peer.address); + } + this->state_ = ESPNOW_STATE_ENABLED; +} + +void ESPNowComponent::disable() { + if (this->state_ == ESPNOW_STATE_DISABLED) + return; + + ESP_LOGD(TAG, "Disabling"); + this->state_ = ESPNOW_STATE_DISABLED; + + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + + for (auto peer : this->peers_) { + this->del_peer(peer.address); + } + + esp_err_t err = esp_now_deinit(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + } +} + +void ESPNowComponent::apply_wifi_channel() { + if (this->state_ == ESPNOW_STATE_DISABLED) { + ESP_LOGE(TAG, "Cannot set channel when ESPNOW disabled"); + this->mark_failed(); + return; + } + + if (this->is_wifi_enabled()) { + ESP_LOGE(TAG, "Cannot set channel when Wi-Fi enabled"); + this->mark_failed(); + return; + } + + ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +void ESPNowComponent::loop() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { + int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); + if (new_channel != this->wifi_channel_) { + ESP_LOGI(TAG, "Wifi Channel is changed from %d to %d.", this->wifi_channel_, new_channel); + this->wifi_channel_ = new_channel; + } + } +#endif + + // Process received packets + ESPNowPacket *packet = this->receive_packet_queue_.pop(); + while (packet != nullptr) { + switch (packet->type_) { + case ESPNowPacket::RECEIVED: { + const ESPNowRecvInfo info = packet->get_receive_info(); + if (!esp_now_is_peer_exist(info.src_addr)) { + if (this->auto_add_peer_) { + this->add_peer(info.src_addr); + } else { + for (auto *handler : this->unknown_peer_handlers_) { + if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } + } + // Intentionally left as if instead of else in case the peer is added above + if (esp_now_is_peer_exist(info.src_addr)) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "<<< [%s -> %s] %s", format_mac_address_pretty(info.src_addr).c_str(), + format_mac_address_pretty(info.des_addr).c_str(), + format_hex_pretty(packet->packet_.receive.data, packet->packet_.receive.size).c_str()); +#endif + if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + for (auto *handler : this->broadcasted_handlers_) { + if (handler->on_broadcasted(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } else { + for (auto *handler : this->received_handlers_) { + if (handler->on_received(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } + } + break; + } + case ESPNowPacket::SENT: { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, ">>> [%s] %s", format_mac_address_pretty(packet->packet_.sent.address).c_str(), + 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); + this->send_packet_pool_.release(this->current_send_packet_); + this->current_send_packet_ = nullptr; // Reset current packet after sending + } + break; + } + default: + break; + } + // Return the packet to the pool + this->receive_packet_pool_.release(packet); + packet = this->receive_packet_queue_.pop(); + } + + // Process sending packet queue + if (this->current_send_packet_ == nullptr) { + this->send_(); + } + + // 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); + } + + // 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_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, + const send_callback_t &callback) { + if (this->state_ != ESPNOW_STATE_ENABLED) { + return ESP_ERR_ESPNOW_NOT_INIT; + } else if (this->is_failed()) { + return ESP_ERR_ESPNOW_FAILED; + } else if (peer_address == 0ULL) { + 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) { + 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_) { + esp_err_t err = this->add_peer(peer_address); + if (err != ESP_OK) { + return err; + } + } else { + return ESP_ERR_ESPNOW_PEER_NOT_PAIRED; + } + } + // Allocate a packet from the pool + ESPNowSendPacket *packet = this->send_packet_pool_.allocate(); + if (packet == nullptr) { + this->send_packet_queue_.increment_dropped_count(); + ESP_LOGE(TAG, "Failed to allocate send packet from pool"); + this->status_momentary_warning("send-packet-pool-full"); + return ESP_ERR_ESPNOW_NO_MEM; + } + // Load the packet data + packet->load_data(peer_address, payload, size, callback); + // Push the packet to the send queue + this->send_packet_queue_.push(packet); + return ESP_OK; +} + +void ESPNowComponent::send_() { + ESPNowSendPacket *packet = this->send_packet_queue_.pop(); + if (packet == nullptr) { + return; // No packets to send + } + + this->current_send_packet_ = packet; + esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send packet to %s - %s", format_mac_address_pretty(packet->address_).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + if (packet->callback_ != nullptr) { + packet->callback_(err); + } + this->status_momentary_warning("send-failed"); + this->send_packet_pool_.release(packet); + this->current_send_packet_ = nullptr; // Reset current packet + return; + } +} + +esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { + if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { + return ESP_ERR_ESPNOW_NOT_INIT; + } + + if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { + this->mark_failed(); + return ESP_ERR_INVALID_MAC; + } + + if (!esp_now_is_peer_exist(peer)) { + esp_now_peer_info_t peer_info = {}; + memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); + esp_err_t err = esp_now_add_peer(&peer_info); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to add peer %s - %s", format_mac_address_pretty(peer).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + this->status_momentary_warning("peer-add-failed"); + return err; + } + } + bool found = false; + for (auto &it : this->peers_) { + if (it == peer) { + found = true; + break; + } + } + if (!found) { + ESPNowPeer new_peer; + memcpy(new_peer.address, peer, ESP_NOW_ETH_ALEN); + this->peers_.push_back(new_peer); + } + + return ESP_OK; +} + +esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { + if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { + return ESP_ERR_ESPNOW_NOT_INIT; + } + if (esp_now_is_peer_exist(peer)) { + esp_err_t err = esp_now_del_peer(peer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to delete peer %s - %s", format_mac_address_pretty(peer).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + this->status_momentary_warning("peer-del-failed"); + return err; + } + } + for (auto it = this->peers_.begin(); it != this->peers_.end(); ++it) { + if (*it == peer) { + this->peers_.erase(it); + break; + } + } + return ESP_OK; +} + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h new file mode 100644 index 0000000000..3a523d1f7e --- /dev/null +++ b/esphome/components/espnow/espnow_component.h @@ -0,0 +1,182 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +#ifdef USE_ESP32 + +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "espnow_packet.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace esphome::espnow { + +// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +using peer_address_t = std::array; + +enum class ESPNowTriggers : uint8_t { + TRIGGER_NONE = 0, + ON_NEW_PEER = 1, + ON_RECEIVED = 2, + ON_BROADCASTED = 3, + ON_SUCCEED = 10, + ON_FAILED = 11, +}; + +enum ESPNowState : uint8_t { + /** Nothing has been initialized yet. */ + ESPNOW_STATE_OFF = 0, + /** ESPNOW is disabled. */ + ESPNOW_STATE_DISABLED, + /** ESPNOW is enabled. */ + ESPNOW_STATE_ENABLED, +}; + +struct ESPNowPeer { + uint8_t address[ESP_NOW_ETH_ALEN]; // MAC address of the peer + + bool operator==(const ESPNowPeer &other) const { return memcmp(this->address, other.address, ESP_NOW_ETH_ALEN) == 0; } + bool operator==(const uint8_t *other) const { return memcmp(this->address, other, ESP_NOW_ETH_ALEN) == 0; } +}; + +/// Handler interface for receiving ESPNow packets from unknown peers +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowUnknownPeerHandler { + public: + /// Called when an ESPNow packet is received from an unknown peer + /// @param info Information about the received packet (sender MAC, etc.) + /// @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; +}; + +/// Handler interface for receiving ESPNow packets +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowReceivedPacketHandler { + public: + /// Called when an ESPNow packet is received + /// @param info Information about the received packet (sender MAC, etc.) + /// @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_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; +/// Handler interface for receiving broadcasted ESPNow packets +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowBroadcastedHandler { + public: + /// Called when a broadcasted ESPNow packet is received + /// @param info Information about the received packet (sender MAC, etc.) + /// @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_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; + +class ESPNowComponent : public Component { + public: + ESPNowComponent(); + void setup() override; + void loop() override; + void dump_config() override; + + float get_setup_priority() const override { return setup_priority::LATE; } + + // Add a peer to the internal list of peers + void add_peer(peer_address_t address) { + ESPNowPeer peer; + memcpy(peer.address, address.data(), ESP_NOW_ETH_ALEN); + this->peers_.push_back(peer); + } + // Add a peer with the esp_now api and add to the internal list if doesnt exist already + esp_err_t add_peer(const uint8_t *peer); + // Remove a peer with the esp_now api and remove from the internal list if exists + esp_err_t del_peer(const uint8_t *peer); + + void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } + void apply_wifi_channel(); + + void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } + + void enable(); + void disable(); + bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; }; + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + bool is_wifi_enabled(); + + /// @brief Queue a packet to be sent to a specific peer address. + /// This method will add the packet to the internal queue and + /// call the callback when the packet is sent. + /// Only one packet will be sent at any given time and the next one will not be sent until + /// the previous one has been acknowledged or failed. + /// @param peer_address MAC address of the peer to send the packet to + /// @param payload Data payload to send + /// @param callback Callback to call when the send operation is complete + /// @return ESP_OK on success, or an error code on failure + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, + const send_callback_t &callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), callback); + } + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, + const send_callback_t &callback = nullptr); + + void register_received_handler(ESPNowReceivedPacketHandler *handler) { this->received_handlers_.push_back(handler); } + void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { + this->unknown_peer_handlers_.push_back(handler); + } + void register_broadcasted_handler(ESPNowBroadcastedHandler *handler) { + this->broadcasted_handlers_.push_back(handler); + } + + protected: + friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); +#else + friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); +#endif + + void enable_(); + void send_(); + + std::vector unknown_peer_handlers_; + std::vector received_handlers_; + std::vector broadcasted_handlers_; + + std::vector peers_{}; + + uint8_t own_address_[ESP_NOW_ETH_ALEN]{0}; + LockFreeQueue receive_packet_queue_{}; + EventPool receive_packet_pool_{}; + + LockFreeQueue send_packet_queue_{}; + EventPool send_packet_pool_{}; + ESPNowSendPacket *current_send_packet_{nullptr}; // Currently sending packet, nullptr if none + + uint8_t wifi_channel_{0}; + ESPNowState state_{ESPNOW_STATE_OFF}; + + bool auto_add_peer_{false}; + bool enable_on_boot_{true}; +}; + +extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h new file mode 100644 index 0000000000..ceda1b7683 --- /dev/null +++ b/esphome/components/espnow/espnow_err.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include + +namespace esphome::espnow { + +static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h new file mode 100644 index 0000000000..d39f7d2c24 --- /dev/null +++ b/esphome/components/espnow/espnow_packet.h @@ -0,0 +1,166 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "espnow_err.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +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}; + +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 + // light sleep is not enabled +}; + +struct ESPNowRecvInfo { + uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ + uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ + wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ +}; + +using send_callback_t = std::function; + +class ESPNowPacket { + public: + // NOLINTNEXTLINE(readability-identifier-naming) + enum esp_now_packet_type_t : uint8_t { + RECEIVED, + SENT, + }; + + // Constructor for received data + ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + this->init_received_data_(info, data, size); + }; + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // Constructor for sent data + ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { + this->init_sent_data(info->src_addr, status); + } +#else + // Constructor for sent data + ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } +#endif + + // Default constructor for pre-allocation in pool + ESPNowPacket() {} + + void release() {} + + void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + this->type_ = RECEIVED; + this->init_received_data_(info, data, size); + } + + void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { + this->type_ = SENT; + this->init_sent_data_(mac_addr, status); + } + + // Disable copy to prevent double-delete + ESPNowPacket(const ESPNowPacket &) = delete; + ESPNowPacket &operator=(const ESPNowPacket &) = delete; + + 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 + } receive; + + // NOLINTNEXTLINE(readability-identifier-naming) + struct sent_data { + uint8_t address[ESP_NOW_ETH_ALEN]; + esp_now_send_status_t status; + } sent; + } packet_; + + esp_now_packet_type_t type_; + + esp_now_packet_type_t type() const { return this->type_; } + const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } + + private: + void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; + this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; + + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); + } + + void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; + } +}; + +class ESPNowSendPacket { + public: + ESPNowSendPacket(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &&callback) + : callback_(callback) { + this->init_data_(peer_address, payload, size); + } + ESPNowSendPacket(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + this->init_data_(peer_address, payload, size); + } + + // Default constructor for pre-allocation in pool + ESPNowSendPacket() {} + + void release() {} + + // Disable copy to prevent double-delete + ESPNowSendPacket(const ESPNowSendPacket &) = delete; + ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; + + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + this->init_data_(peer_address, payload, size); + this->callback_ = callback; + } + + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + this->init_data_(peer_address, payload, size); + 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 + + 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) { + this->size_ = 0; + return; + } + this->size_ = size; + memcpy(this->data_, payload, this->size_); + } +}; + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml new file mode 100644 index 0000000000..abb31c12b8 --- /dev/null +++ b/tests/components/espnow/common.yaml @@ -0,0 +1,52 @@ +espnow: + auto_add_peer: false + channel: 1 + peers: + - 11:22:33:44:55:66 + on_receive: + - logger.log: + format: "Received from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi + - espnow.send: + address: 11:22:33:44:55:66 + data: "Hello from ESPHome" + on_sent: + - logger.log: "ESPNow message sent successfully" + on_error: + - logger.log: "ESPNow message failed to send" + wait_for_sent: true + continue_on_error: true + + - espnow.send: + address: 11:22:33:44:55:66 + data: [0x01, 0x02, 0x03, 0x04, 0x05] + - espnow.send: + address: 11:22:33:44:55:66 + data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.broadcast: + data: "Hello, World!" + - espnow.broadcast: + data: [0x01, 0x02, 0x03, 0x04, 0x05] + - espnow.broadcast: + data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.peer.add: + address: 11:22:33:44:55:66 + - espnow.peer.delete: + address: 11:22:33:44:55:66 + on_broadcast: + - logger.log: + format: "Broadcast from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi + on_unknown_peer: + - logger.log: + format: "Unknown peer: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi diff --git a/tests/components/espnow/test.esp32-idf.yaml b/tests/components/espnow/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/espnow/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From e4dc62ea74e3b219244c8fe7eedce499bad15a32 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 1 Aug 2025 06:53:40 +0200 Subject: [PATCH 11/60] [nrf52, debug] debug component for nrf52 (#8315) --- esphome/components/debug/__init__.py | 5 + esphome/components/debug/debug_zephyr.cpp | 281 ++++++++++++++++++ esphome/components/nrf52/__init__.py | 4 +- .../components/debug/test.nrf52-adafruit.yaml | 1 + tests/components/debug/test.nrf52-mcumgr.yaml | 1 + 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 esphome/components/debug/debug_zephyr.cpp create mode 100644 tests/components/debug/test.nrf52-adafruit.yaml create mode 100644 tests/components/debug/test.nrf52-mcumgr.yaml diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 500dfac1fe..b8dabc3374 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +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 ( @@ -10,6 +11,7 @@ from esphome.const import ( CONF_LOOP_TIME, PlatformFramework, ) +from esphome.core import CORE CODEOWNERS = ["@OttoWinter"] DEPENDENCIES = ["logger"] @@ -44,6 +46,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + if CORE.using_zephyr: + zephyr_add_prj_conf("HWINFO", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -62,5 +66,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "debug_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp new file mode 100644 index 0000000000..9a361b158f --- /dev/null +++ b/esphome/components/debug/debug_zephyr.cpp @@ -0,0 +1,281 @@ +#include "debug_component.h" +#ifdef USE_ZEPHYR +#include +#include "esphome/core/log.h" +#include +#include +#include + +#define BOOTLOADER_VERSION_REGISTER NRF_TIMER2->CC[0] + +namespace esphome { +namespace debug { + +static const char *const TAG = "debug"; +constexpr std::uintptr_t MBR_PARAM_PAGE_ADDR = 0xFFC; +constexpr std::uintptr_t MBR_BOOTLOADER_ADDR = 0xFF8; + +static void show_reset_reason(std::string &reset_reason, bool set, const char *reason) { + if (!set) { + return; + } + if (!reset_reason.empty()) { + reset_reason += ", "; + } + reset_reason += reason; +} + +inline uint32_t read_mem_u32(uintptr_t addr) { + return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) +} + +std::string DebugComponent::get_reset_reason_() { + uint32_t cause; + auto ret = hwinfo_get_reset_cause(&cause); + if (ret) { + ESP_LOGE(TAG, "Unable to get reset cause: %d", ret); + return ""; + } + std::string reset_reason; + + show_reset_reason(reset_reason, cause & RESET_PIN, "External pin"); + show_reset_reason(reset_reason, cause & RESET_SOFTWARE, "Software reset"); + show_reset_reason(reset_reason, cause & RESET_BROWNOUT, "Brownout (drop in voltage)"); + show_reset_reason(reset_reason, cause & RESET_POR, "Power-on reset (POR)"); + show_reset_reason(reset_reason, cause & RESET_WATCHDOG, "Watchdog timer expiration"); + show_reset_reason(reset_reason, cause & RESET_DEBUG, "Debug event"); + show_reset_reason(reset_reason, cause & RESET_SECURITY, "Security violation"); + show_reset_reason(reset_reason, cause & RESET_LOW_POWER_WAKE, "Waking up from low power mode"); + show_reset_reason(reset_reason, cause & RESET_CPU_LOCKUP, "CPU lock-up detected"); + show_reset_reason(reset_reason, cause & RESET_PARITY, "Parity error"); + show_reset_reason(reset_reason, cause & RESET_PLL, "PLL error"); + show_reset_reason(reset_reason, cause & RESET_CLOCK, "Clock error"); + show_reset_reason(reset_reason, cause & RESET_HARDWARE, "Hardware reset"); + show_reset_reason(reset_reason, cause & RESET_USER, "User reset"); + show_reset_reason(reset_reason, cause & RESET_TEMPERATURE, "Temperature reset"); + + ESP_LOGD(TAG, "Reset Reason: %s", reset_reason.c_str()); + return reset_reason; +} + +uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } + +void DebugComponent::get_device_info_(std::string &device_info) { + std::string supply = "Main supply status: "; + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) { + supply += "Normal voltage."; + } else { + supply += "High voltage."; + } + ESP_LOGD(TAG, "%s", supply.c_str()); + device_info += "|" + supply; + + std::string reg0 = "Regulator stage 0: "; + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { + reg0 += nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; + reg0 += ", "; + switch (NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) { + case (UICR_REGOUT0_VOUT_DEFAULT << UICR_REGOUT0_VOUT_Pos): + reg0 += "1.8V (default)"; + break; + case (UICR_REGOUT0_VOUT_1V8 << UICR_REGOUT0_VOUT_Pos): + reg0 += "1.8V"; + break; + case (UICR_REGOUT0_VOUT_2V1 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.1V"; + break; + case (UICR_REGOUT0_VOUT_2V4 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.4V"; + break; + case (UICR_REGOUT0_VOUT_2V7 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.7V"; + break; + case (UICR_REGOUT0_VOUT_3V0 << UICR_REGOUT0_VOUT_Pos): + reg0 += "3.0V"; + break; + case (UICR_REGOUT0_VOUT_3V3 << UICR_REGOUT0_VOUT_Pos): + reg0 += "3.3V"; + break; + default: + reg0 += "???V"; + } + } else { + reg0 += "disabled"; + } + ESP_LOGD(TAG, "%s", reg0.c_str()); + device_info += "|" + reg0; + + std::string reg1 = "Regulator stage 1: "; + reg1 += nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; + ESP_LOGD(TAG, "%s", reg1.c_str()); + device_info += "|" + reg1; + + std::string usb_power = "USB power state: "; + if (nrf_power_usbregstatus_vbusdet_get(NRF_POWER)) { + if (nrf_power_usbregstatus_outrdy_get(NRF_POWER)) { + /**< From the power viewpoint, USB is ready for working. */ + usb_power += "ready"; + } else { + /**< The USB power is detected, but USB power regulator is not ready. */ + usb_power += "connected (regulator is not ready)"; + } + } else { + /**< No power on USB lines detected. */ + usb_power += "disconected"; + } + ESP_LOGD(TAG, "%s", usb_power.c_str()); + device_info += "|" + usb_power; + + bool enabled; + nrf_power_pof_thr_t pof_thr; + + pof_thr = nrf_power_pofcon_get(NRF_POWER, &enabled); + std::string pof = "Power-fail comparator: "; + if (enabled) { + switch (pof_thr) { + case POWER_POFCON_THRESHOLD_V17: + pof += "1.7V"; + break; + case POWER_POFCON_THRESHOLD_V18: + pof += "1.8V"; + break; + case POWER_POFCON_THRESHOLD_V19: + pof += "1.9V"; + break; + case POWER_POFCON_THRESHOLD_V20: + pof += "2.0V"; + break; + case POWER_POFCON_THRESHOLD_V21: + pof += "2.1V"; + break; + case POWER_POFCON_THRESHOLD_V22: + pof += "2.2V"; + break; + case POWER_POFCON_THRESHOLD_V23: + pof += "2.3V"; + break; + case POWER_POFCON_THRESHOLD_V24: + pof += "2.4V"; + break; + case POWER_POFCON_THRESHOLD_V25: + pof += "2.5V"; + break; + case POWER_POFCON_THRESHOLD_V26: + pof += "2.6V"; + break; + case POWER_POFCON_THRESHOLD_V27: + pof += "2.7V"; + break; + case POWER_POFCON_THRESHOLD_V28: + pof += "2.8V"; + break; + } + + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { + pof += ", VDDH: "; + switch (nrf_power_pofcon_vddh_get(NRF_POWER)) { + case NRF_POWER_POFTHRVDDH_V27: + pof += "2.7V"; + break; + case NRF_POWER_POFTHRVDDH_V28: + pof += "2.8V"; + break; + case NRF_POWER_POFTHRVDDH_V29: + pof += "2.9V"; + break; + case NRF_POWER_POFTHRVDDH_V30: + pof += "3.0V"; + break; + case NRF_POWER_POFTHRVDDH_V31: + pof += "3.1V"; + break; + case NRF_POWER_POFTHRVDDH_V32: + pof += "3.2V"; + break; + case NRF_POWER_POFTHRVDDH_V33: + pof += "3.3V"; + break; + case NRF_POWER_POFTHRVDDH_V34: + pof += "3.4V"; + break; + case NRF_POWER_POFTHRVDDH_V35: + pof += "3.5V"; + break; + case NRF_POWER_POFTHRVDDH_V36: + pof += "3.6V"; + break; + case NRF_POWER_POFTHRVDDH_V37: + pof += "3.7V"; + break; + case NRF_POWER_POFTHRVDDH_V38: + pof += "3.8V"; + break; + case NRF_POWER_POFTHRVDDH_V39: + pof += "3.9V"; + break; + case NRF_POWER_POFTHRVDDH_V40: + pof += "4.0V"; + break; + case NRF_POWER_POFTHRVDDH_V41: + pof += "4.1V"; + break; + case NRF_POWER_POFTHRVDDH_V42: + pof += "4.2V"; + break; + } + } + } else { + pof += "disabled"; + } + ESP_LOGD(TAG, "%s", pof.c_str()); + device_info += "|" + pof; + + auto package = [](uint32_t value) { + switch (value) { + case 0x2004: + return "QIxx - 7x7 73-pin aQFN"; + case 0x2000: + return "QFxx - 6x6 48-pin QFN"; + case 0x2005: + return "CKxx - 3.544 x 3.607 WLCSP"; + } + return "Unspecified"; + }; + + ESP_LOGD(TAG, "Code page size: %u, code size: %u, device id: 0x%08x%08x", NRF_FICR->CODEPAGESIZE, NRF_FICR->CODESIZE, + NRF_FICR->DEVICEID[1], NRF_FICR->DEVICEID[0]); + ESP_LOGD(TAG, "Encryption root: 0x%08x%08x%08x%08x, Identity Root: 0x%08x%08x%08x%08x", NRF_FICR->ER[0], + NRF_FICR->ER[1], NRF_FICR->ER[2], NRF_FICR->ER[3], NRF_FICR->IR[0], NRF_FICR->IR[1], NRF_FICR->IR[2], + NRF_FICR->IR[3]); + ESP_LOGD(TAG, "Device address type: %s, address: %s", (NRF_FICR->DEVICEADDRTYPE & 0x1 ? "Random" : "Public"), + get_mac_address_pretty().c_str()); + ESP_LOGD(TAG, "Part code: nRF%x, version: %c%c%c%c, package: %s", NRF_FICR->INFO.PART, + NRF_FICR->INFO.VARIANT >> 24 & 0xFF, NRF_FICR->INFO.VARIANT >> 16 & 0xFF, NRF_FICR->INFO.VARIANT >> 8 & 0xFF, + NRF_FICR->INFO.VARIANT & 0xFF, package(NRF_FICR->INFO.PACKAGE)); + ESP_LOGD(TAG, "RAM: %ukB, Flash: %ukB, production test: %sdone", NRF_FICR->INFO.RAM, NRF_FICR->INFO.FLASH, + (NRF_FICR->PRODTEST[0] == 0xBB42319F ? "" : "not ")); + ESP_LOGD( + TAG, "GPIO as NFC pins: %s, GPIO as nRESET pin: %s", + YESNO((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) == (UICR_NFCPINS_PROTECT_NFC << UICR_NFCPINS_PROTECT_Pos)), + YESNO(((NRF_UICR->PSELRESET[0] & UICR_PSELRESET_CONNECT_Msk) != + (UICR_PSELRESET_CONNECT_Connected << UICR_PSELRESET_CONNECT_Pos)) || + ((NRF_UICR->PSELRESET[1] & UICR_PSELRESET_CONNECT_Msk) != + (UICR_PSELRESET_CONNECT_Connected << UICR_PSELRESET_CONNECT_Pos)))); + +#ifdef USE_BOOTLOADER_MCUBOOT + ESP_LOGD(TAG, "bootloader: mcuboot"); +#else + ESP_LOGD(TAG, "bootloader: Adafruit, version %u.%u.%u", (BOOTLOADER_VERSION_REGISTER >> 16) & 0xFF, + (BOOTLOADER_VERSION_REGISTER >> 8) & 0xFF, BOOTLOADER_VERSION_REGISTER & 0xFF); + ESP_LOGD(TAG, "MBR bootloader addr 0x%08x, UICR bootloader addr 0x%08x", read_mem_u32(MBR_BOOTLOADER_ADDR), + NRF_UICR->NRFFW[0]); + ESP_LOGD(TAG, "MBR param page addr 0x%08x, UICR param page addr 0x%08x", read_mem_u32(MBR_PARAM_PAGE_ADDR), + NRF_UICR->NRFFW[1]); +#endif +} + +void DebugComponent::update_platform_() {} + +} // namespace debug +} // namespace esphome +#endif diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 17807b9e2b..908a855f70 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -124,7 +124,9 @@ async def to_code(config: ConfigType) -> None: ], ) - if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: + if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: + cg.add_define("USE_BOOTLOADER_MCUBOOT") + else: # make sure that firmware.zip is created # for Adafruit_nRF52_Bootloader cg.add_platformio_option("board_upload.protocol", "nrfutil") diff --git a/tests/components/debug/test.nrf52-adafruit.yaml b/tests/components/debug/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/debug/test.nrf52-mcumgr.yaml b/tests/components/debug/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From f761404bf6210e3c616f9f0c1fea3faf3f7e2e01 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 1 Aug 2025 06:54:20 +0200 Subject: [PATCH 12/60] [nrf52, gpio] check different port notation (#9737) --- tests/components/gpio/test.nrf52-adafruit.yaml | 4 ++-- tests/components/gpio/test.nrf52-mcumgr.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index 3ca285117d..912b9537c4 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -5,10 +5,10 @@ binary_sensor: output: - platform: gpio - pin: 3 + pin: P0.3 id: gpio_output switch: - platform: gpio - pin: 4 + pin: P1.2 id: gpio_switch diff --git a/tests/components/gpio/test.nrf52-mcumgr.yaml b/tests/components/gpio/test.nrf52-mcumgr.yaml index 3ca285117d..912b9537c4 100644 --- a/tests/components/gpio/test.nrf52-mcumgr.yaml +++ b/tests/components/gpio/test.nrf52-mcumgr.yaml @@ -5,10 +5,10 @@ binary_sensor: output: - platform: gpio - pin: 3 + pin: P0.3 id: gpio_output switch: - platform: gpio - pin: 4 + pin: P1.2 id: gpio_switch From 940a8b43fa189ae34038b19ca52bd59419b1f434 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:07:11 +1000 Subject: [PATCH 13/60] [esp32] Add config option to execute from PSRAM (#9907) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/debug/sensor.py | 3 +- esphome/components/esp32/__init__.py | 70 ++++++++++++++----- esphome/components/lvgl/__init__.py | 3 +- esphome/components/mipi_spi/display.py | 3 +- esphome/components/psram/__init__.py | 3 +- esphome/config.py | 33 +++++++-- tests/component_tests/conftest.py | 3 +- tests/component_tests/esp32/test_esp32.py | 46 ++++++++++-- tests/component_tests/types.py | 1 + tests/components/esp32/test.esp32-s3-idf.yaml | 12 ++++ 10 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 tests/components/esp32/test.esp32-s3-idf.yaml diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 4669095d5d..4484f15935 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.esp32 import CONF_CPU_FREQUENCY +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BLOCK, @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { ), cv.Optional(CONF_PSRAM): cv.All( cv.only_on_esp32, - cv.requires_component("psram"), + cv.requires_component(PSRAM_DOMAIN), sensor.sensor_schema( unit_of_measurement=UNIT_BYTES, icon=ICON_COUNTER, diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8d72ff3685..05a79553a4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -76,6 +76,7 @@ CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" +CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" ASSERTION_LEVELS = { @@ -519,32 +520,59 @@ def _detect_variant(value): def final_validate(config): - if not ( - pio_options := fv.full_config.get()[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS) - ): - # Not specified or empty - return config - - pio_flash_size_key = "board_upload.flash_size" - pio_partitions_key = "board_build.partitions" - if CONF_PARTITIONS in config and pio_partitions_key in pio_options: - raise cv.Invalid( - f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" - ) - - if pio_flash_size_key in pio_options: - raise cv.Invalid( - f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" - ) + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + errs = [] + full_config = fv.full_config.get() + if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS): + pio_flash_size_key = "board_upload.flash_size" + pio_partitions_key = "board_build.partitions" + if CONF_PARTITIONS in config and pio_partitions_key in pio_options: + errs.append( + cv.Invalid( + f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" + ) + ) + if pio_flash_size_key in pio_options: + errs.append( + cv.Invalid( + f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32 and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK]) and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED] ): - raise cv.Invalid( - f"{CONF_IGNORE_EFUSE_MAC_CRC} is not supported on {config[CONF_VARIANT]}" + errs.append( + cv.Invalid( + f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC], + ) ) + if ( + config.get(CONF_FRAMEWORK, {}) + .get(CONF_ADVANCED, {}) + .get(CONF_EXECUTE_FROM_PSRAM) + ): + if config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_EXECUTE_FROM_PSRAM}' is only supported on {VARIANT_ESP32S3} variant", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], + ) + ) + if PSRAM_DOMAIN not in full_config: + errs.append( + cv.Invalid( + f"'{CONF_EXECUTE_FROM_PSRAM}' requires PSRAM to be configured", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) return config @@ -627,6 +655,7 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True ): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -792,6 +821,9 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 0cd65d298f..a37f4570f3 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -4,6 +4,7 @@ from esphome.automation import build_automation, register_action, validate_autom import esphome.codegen as cg from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING from esphome.components.display import Display +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, @@ -219,7 +220,7 @@ def final_validation(configs): draw_rounding, config[CONF_DRAW_ROUNDING] ) buffer_frac = config[CONF_BUFFER_SIZE] - if CORE.is_esp32 and buffer_frac > 0.5 and "psram" not in global_config: + if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: LOGGER.warning("buffer_size: may need to be reduced without PSRAM") for image_id in lv_images_used: path = global_config.get_path_for_id(image_id)[:-1] diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index cb2de6c3d7..e891e2daad 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -25,6 +25,7 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA @@ -292,7 +293,7 @@ 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 - if "psram" not in global_config and CONF_BUFFER_SIZE not in config: + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: if not requires_buffer(config): return config # No buffer needed, so no need to set a buffer size # If PSRAM is not enabled, choose a small buffer size by default diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 9299cdcd0e..fd7e70a055 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -28,12 +28,13 @@ from esphome.core import CORE import esphome.final_validate as fv CODEOWNERS = ["@esphome/core"] +DOMAIN = "psram" DEPENDENCIES = [PLATFORM_ESP32] _LOGGER = logging.getLogger(__name__) -psram_ns = cg.esphome_ns.namespace("psram") +psram_ns = cg.esphome_ns.namespace(DOMAIN) PsramComponent = psram_ns.class_("PsramComponent", cg.Component) TYPE_QUAD = "quad" diff --git a/esphome/config.py b/esphome/config.py index 670cbe7233..cf7a232d8e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -329,6 +329,28 @@ class ConfigValidationStep(abc.ABC): def run(self, result: Config) -> None: ... # noqa: E704 +class LoadTargetPlatformValidationStep(ConfigValidationStep): + """Load target platform step.""" + + def __init__(self, domain: str, conf: ConfigType): + self.domain = domain + self.conf = conf + + def run(self, result: Config) -> None: + if self.conf is None: + result[self.domain] = self.conf = {} + result.add_output_path([self.domain], self.domain) + component = get_component(self.domain) + + result[self.domain] = self.conf + path = [self.domain] + CORE.loaded_integrations.add(self.domain) + + result.add_validation_step( + SchemaValidationStep(self.domain, path, self.conf, component) + ) + + class LoadValidationStep(ConfigValidationStep): """Load step, this step is called once for each domain config fragment. @@ -582,16 +604,18 @@ class MetadataValidationStep(ConfigValidationStep): ) return for i, part_conf in enumerate(self.conf): + path = self.path + [i] result.add_validation_step( - SchemaValidationStep( - self.domain, self.path + [i], part_conf, self.comp - ) + SchemaValidationStep(self.domain, path, part_conf, self.comp) ) + result.add_validation_step(FinalValidateValidationStep(path, self.comp)) + return result.add_validation_step( SchemaValidationStep(self.domain, self.path, self.conf, self.comp) ) + result.add_validation_step(FinalValidateValidationStep(self.path, self.comp)) class SchemaValidationStep(ConfigValidationStep): @@ -628,7 +652,6 @@ class SchemaValidationStep(ConfigValidationStep): result.set_by_path(self.path, validated) path_context.reset(token) - result.add_validation_step(FinalValidateValidationStep(self.path, self.comp)) class IDPassValidationStep(ConfigValidationStep): @@ -909,7 +932,7 @@ def validate_config( # First run platform validation steps result.add_validation_step( - LoadValidationStep(target_platform, config[target_platform]) + LoadTargetPlatformValidationStep(target_platform, config[target_platform]) ) result.run_validation_steps() diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index b269e23cd6..2045b03502 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -65,6 +65,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, + full_config: dict[str, ConfigType] | None = None, ) -> None: platform, framework = platform_framework.value @@ -83,7 +84,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: CORE.data[platform.value] = platform_data config.path_context.set([]) - final_validate.full_config.set(Config()) + final_validate.full_config.set(full_config or Config()) yield setter diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index fe031c653f..91e96f24d6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,13 @@ import pytest from esphome.components.esp32 import VARIANTS import esphome.config_validation as cv -from esphome.const import PlatformFramework +from esphome.const import CONF_ESPHOME, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable -def test_esp32_config(set_core_config) -> None: +def test_esp32_config( + set_core_config: SetCoreConfigCallable, +) -> None: set_core_config(PlatformFramework.ESP32_IDF) from esphome.components.esp32 import CONFIG_SCHEMA @@ -60,14 +63,49 @@ def test_esp32_config(set_core_config) -> None: r"Option 'variant' does not match selected board. @ data\['variant'\]", id="mismatched_board_variant_config", ), + pytest.param( + { + "variant": "esp32s2", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' is only supported on ESP32S3 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_invalid_for_variant_config", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_requires_psram_config", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"ignore_efuse_mac_crc": True}, + }, + }, + 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", + ), ], ) def test_esp32_configuration_errors( config: Any, error_match: str, + set_core_config: SetCoreConfigCallable, ) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={CONF_ESPHOME: {}}) """Test detection of invalid configuration.""" - from esphome.components.esp32 import CONFIG_SCHEMA + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) diff --git a/tests/component_tests/types.py b/tests/component_tests/types.py index 72b8be4503..ee9d317339 100644 --- a/tests/component_tests/types.py +++ b/tests/component_tests/types.py @@ -18,4 +18,5 @@ class SetCoreConfigCallable(Protocol): *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, + full_config: dict[str, ConfigType] | None = None, ) -> None: ... diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..1d5a5e52a4 --- /dev/null +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -0,0 +1,12 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + execute_from_psram: true + +psram: + mode: octal + speed: 80MHz + +logger: From 20ad1ab4eb625909156c0a7a1034bc45e6779262 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 21:46:52 -1000 Subject: [PATCH 14/60] [wifi] Fix crash during WiFi reconnection on ESP32 with poor signal quality (#9989) --- .../wifi/wifi_component_esp32_arduino.cpp | 18 ++++++++++++++++++ .../components/wifi/wifi_component_esp_idf.cpp | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 3c3e87d332..67b1f565ff 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -283,6 +283,12 @@ bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { if (!this->wifi_mode_(true, {})) return false; + // Check if the STA interface is initialized before using it + if (s_sta_netif == nullptr) { + ESP_LOGW(TAG, "STA interface not initialized"); + return false; + } + esp_netif_dhcp_status_t dhcp_status; esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); if (err != ESP_OK) { @@ -541,6 +547,8 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + // Clear the STA interface handle to prevent use-after-free + s_sta_netif = nullptr; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -630,6 +638,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_STOP: { ESP_LOGV(TAG, "AP stop"); +#ifdef USE_WIFI_AP + // Clear the AP interface handle to prevent use-after-free + s_ap_netif = nullptr; +#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { @@ -719,6 +731,12 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { if (!this->wifi_mode_({}, true)) return false; + // Check if the AP interface is initialized before using it + if (s_ap_netif == nullptr) { + ESP_LOGW(TAG, "AP interface not initialized"); + return false; + } + esp_netif_ip_info_t info; if (manual_ip.has_value()) { info.ip = manual_ip->static_ip; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 0b281e9b80..94f1f5125f 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -473,6 +473,12 @@ bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { if (!this->wifi_mode_(true, {})) return false; + // Check if the STA interface is initialized before using it + if (s_sta_netif == nullptr) { + ESP_LOGW(TAG, "STA interface not initialized"); + return false; + } + esp_netif_dhcp_status_t dhcp_status; esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); if (err != ESP_OK) { @@ -691,6 +697,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + // Clear the STA interface handle to prevent use-after-free + s_sta_netif = nullptr; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -789,6 +797,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); s_ap_started = false; +#ifdef USE_WIFI_AP + // Clear the AP interface handle to prevent use-after-free + s_ap_netif = nullptr; +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; @@ -865,6 +877,12 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { if (!this->wifi_mode_({}, true)) return false; + // Check if the AP interface is initialized before using it + if (s_ap_netif == nullptr) { + ESP_LOGW(TAG, "AP interface not initialized"); + return false; + } + esp_netif_ip_info_t info; if (manual_ip.has_value()) { info.ip = manual_ip->static_ip; From d8a46c74823ee842bfb9b1c3fa72f29b7ed13815 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 1 Aug 2025 21:40:53 +1200 Subject: [PATCH 15/60] [CI] Allow multiple grep options for clang-tidy (#10004) --- .github/workflows/ci.yml | 2 +- script/clang-tidy | 7 ++++++- script/helpers.py | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f290c43f..992918a035 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: pio_cache_key: tidyesp32-idf - id: clang-tidy name: Run script/clang-tidy for ZEPHYR - options: --environment nrf52-tidy --grep USE_ZEPHYR + options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 pio_cache_key: tidy-zephyr ignore_errors: false diff --git a/script/clang-tidy b/script/clang-tidy index 9576b8da8b..2c4a2e36ac 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -205,7 +205,12 @@ def main(): parser.add_argument( "-c", "--changed", action="store_true", help="only run on changed files" ) - parser.add_argument("-g", "--grep", help="only run on files containing value") + parser.add_argument( + "-g", + "--grep", + action="append", + help="only run on files containing value", + ) parser.add_argument( "--split-num", type=int, help="split the files into X jobs.", default=None ) diff --git a/script/helpers.py b/script/helpers.py index 4903521e2d..b346f3a461 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -338,12 +338,12 @@ def filter_changed(files: list[str]) -> list[str]: return files -def filter_grep(files: list[str], value: str) -> list[str]: +def filter_grep(files: list[str], value: list[str]) -> list[str]: matched = [] for file in files: with open(file, encoding="utf-8") as handle: contents = handle.read() - if value in contents: + if any(v in contents for v in value): matched.append(file) return matched From b5f42bc493461464b0ac94668f01dcf7ef04d2ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:53:56 -1000 Subject: [PATCH 16/60] Bump aioesphomeapi from 37.2.1 to 37.2.2 (#10009) 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 b7d259f0c5..0d39e9edcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.1 +aioesphomeapi==37.2.2 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 1f7c59f88d94acc26f36fe42ca5faba92fa430e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:02:29 -1000 Subject: [PATCH 17/60] Bump esptool from 4.9.0 to 5.0.2 (#9983) 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 0d39e9edcf..b834fd9ac3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile -esptool==4.9.0 +esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 aioesphomeapi==37.2.2 From f1877ca084fbc2a13a18f9c41407a43dfe175a54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:16:28 +0000 Subject: [PATCH 18/60] Bump aioesphomeapi from 37.2.2 to 37.2.3 (#10012) 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 b834fd9ac3..bfdb08323e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.2 +aioesphomeapi==37.2.3 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 00d9baed11fa3c8a7330f88c963cd7d87a624817 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 20:26:00 -1000 Subject: [PATCH 19/60] [bluetooth_proxy] Eliminate heap allocations in connection state reporting (#10010) --- esphome/components/api/api.proto | 5 ++- esphome/components/api/api_connection.cpp | 6 +-- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 10 +++-- esphome/components/api/api_pb2.h | 4 +- .../components/bluetooth_proxy/__init__.py | 4 ++ .../bluetooth_proxy/bluetooth_connection.cpp | 24 +++++++++++ .../bluetooth_proxy/bluetooth_connection.h | 3 ++ .../bluetooth_proxy/bluetooth_proxy.cpp | 33 ++++---------- .../bluetooth_proxy/bluetooth_proxy.h | 8 ++-- .../esp32_ble_client/ble_client_base.h | 2 +- esphome/core/defines.h | 1 + script/api_protobuf/api_protobuf.py | 43 ++++++++++++++++++- 13 files changed, 104 insertions(+), 40 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4aa5cc4be0..e0b2c19a21 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1621,7 +1621,10 @@ message BluetoothConnectionsFreeResponse { uint32 free = 1; uint32 limit = 2; - repeated uint64 allocated = 3; + repeated uint64 allocated = 3 [ + (fixed_array_size_define) = "BLUETOOTH_PROXY_MAX_CONNECTIONS", + (fixed_array_skip_zero) = true + ]; } message BluetoothGATTErrorResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8ac6c3b71e..5fff270c99 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1105,10 +1105,8 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bool APIConnection::send_subscribe_bluetooth_connections_free_response( const SubscribeBluetoothConnectionsFreeRequest &msg) { - BluetoothConnectionsFreeResponse resp; - resp.free = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_free(); - resp.limit = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_limit(); - return this->send_message(resp, BluetoothConnectionsFreeResponse::MESSAGE_TYPE); + bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this); + return true; } void APIConnection::bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d4b5700024..ed0e0d7455 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -29,6 +29,7 @@ extend google.protobuf.FieldOptions { optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; optional bool fixed_array_skip_zero = 50009 [default=false]; + optional string fixed_array_size_define = 50010; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 29d0f2842c..8c14153155 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2073,15 +2073,17 @@ void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); buffer.encode_uint32(2, this->limit); - for (auto &it : this->allocated) { - buffer.encode_uint64(3, it, true); + for (const auto &it : this->allocated) { + if (it != 0) { + buffer.encode_uint64(3, it, true); + } } } void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->free); size.add_uint32(1, this->limit); - if (!this->allocated.empty()) { - for (const auto &it : this->allocated) { + for (const auto &it : this->allocated) { + if (it != 0) { size.add_uint64_force(1, it); } } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 524674e6ef..0bc75ef00b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2076,13 +2076,13 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { class BluetoothConnectionsFreeResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; - static constexpr uint8_t ESTIMATED_SIZE = 16; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_connections_free_response"; } #endif uint32_t free{0}; uint32_t limit{0}; - std::vector allocated{}; + std::array allocated{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a1e9d464df..ec1df6a06c 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -87,6 +87,10 @@ async def to_code(config): cg.add(var.set_active(config[CONF_ACTIVE])) await esp32_ble_tracker.register_raw_ble_device(var, config) + # Define max connections for protobuf fixed array + connection_count = len(config.get(CONF_CONNECTIONS, [])) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fd1324dcdc..01c2aa3d22 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -78,6 +78,30 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } +void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { + auto &allocated = this->proxy_->connections_free_response_.allocated; + auto *it = std::find(allocated.begin(), allocated.end(), find_value); + if (it != allocated.end()) { + *it = set_value; + } +} + +void BluetoothConnection::set_address(uint64_t address) { + // If we're clearing an address (disconnecting), update the pre-allocated message + if (address == 0 && this->address_ != 0) { + this->proxy_->connections_free_response_.free++; + this->update_allocated_slot_(this->address_, 0); + } + // If we're setting a new address (connecting), update the pre-allocated message + else if (address != 0 && this->address_ == 0) { + this->proxy_->connections_free_response_.free--; + this->update_allocated_slot_(0, address); + } + + // Call parent implementation to actually set the address + BLEClientBase::set_address(address); +} + void BluetoothConnection::loop() { BLEClientBase::loop(); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 622d257bf8..042868e7a4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -24,12 +24,15 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { esp_err_t notify_characteristic(uint16_t handle, bool enable); + void set_address(uint64_t address) override; + protected: friend class BluetoothProxy; bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); + void update_allocated_slot_(uint64_t find_value, uint64_t set_value); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index de5508c777..a59a33117a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,6 +35,9 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool + this->connections_free_response_.limit = this->connections_.size(); + this->connections_free_response_.free = this->connections_.size(); + this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -134,20 +137,6 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connections_.size()); } -int BluetoothProxy::get_bluetooth_connections_free() { - int free = 0; - for (auto *connection : this->connections_) { - if (connection->address_ == 0) { - free++; - ESP_LOGV(TAG, "[%d] Free connection", connection->get_connection_index()); - } else { - ESP_LOGV(TAG, "[%d] Used connection by [%s]", connection->get_connection_index(), - connection->address_str().c_str()); - } - } - return free; -} - void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { for (auto *connection : this->connections_) { @@ -439,17 +428,13 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui this->api_connection_->send_message(call, api::BluetoothDeviceConnectionResponse::MESSAGE_TYPE); } void BluetoothProxy::send_connections_free() { - if (this->api_connection_ == nullptr) - return; - api::BluetoothConnectionsFreeResponse call; - call.free = this->get_bluetooth_connections_free(); - call.limit = this->get_bluetooth_connections_limit(); - for (auto *connection : this->connections_) { - if (connection->address_ != 0) { - call.allocated.push_back(connection->address_); - } + if (this->api_connection_ != nullptr) { + this->send_connections_free(this->api_connection_); } - this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); +} + +void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { + api_connection->send_message(this->connections_free_response_, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d249515fdf..70deef1ebd 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -49,6 +49,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { + friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); #ifdef USE_ESP32_BLE_DEVICE @@ -74,15 +75,13 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); - int get_bluetooth_connections_free(); - int get_bluetooth_connections_limit() { return this->connections_.size(); } - void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK); void send_connections_free(); + void send_connections_free(api::APIConnection *api_connection); void send_gatt_services_done(uint64_t address); void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error); void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK); @@ -149,6 +148,9 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 3: 4-byte types uint32_t last_advertisement_flush_time_{0}; + // Pre-allocated response message - always ready to send + api::BluetoothConnectionsFreeResponse connections_free_response_; + // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 457a88ec1d..0a2fda4476 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -48,7 +48,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } - void set_address(uint64_t address) { + virtual void set_address(uint64_t address) { this->address_ = address; this->remote_bda_[0] = (address >> 40) & 0xFF; this->remote_bda_[1] = (address >> 32) & 0xFF; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e226f748a8..55652e443e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -147,6 +147,7 @@ #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_BLUETOOTH_PROXY +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE #define USE_ESP32_BLE_CLIENT diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 24e2b25e90..fa2f87d98d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -342,6 +342,11 @@ def create_field_type_info( # Check if this repeated field has fixed_array_size option if (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None: return FixedArrayRepeatedType(field, fixed_size) + # Check if this repeated field has fixed_array_size_define option + if ( + size_define := get_field_opt(field, pb.fixed_array_size_define) + ) is not None: + return FixedArrayRepeatedType(field, size_define) return RepeatedTypeInfo(field) # Check for fixed_array_size option on bytes fields @@ -1066,9 +1071,10 @@ class FixedArrayRepeatedType(TypeInfo): control how many items we receive when decoding. """ - def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None: + def __init__(self, field: descriptor.FieldDescriptorProto, size: int | str) -> None: super().__init__(field) self.array_size = size + self.is_define = isinstance(size, str) # Check if we should skip encoding when all elements are zero # Use getattr to handle older versions of api_options_pb2 self.skip_zero = get_field_opt( @@ -1113,6 +1119,14 @@ class FixedArrayRepeatedType(TypeInfo): # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += " if (it != 0) {\n" + o += f" {encode_element('it')}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] @@ -1123,6 +1137,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += f" {encode_element('it')}\n" + o += "}" + return o + # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") @@ -1153,6 +1174,14 @@ class FixedArrayRepeatedType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # If skip_zero is enabled, wrap size calculation in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : {name}) {{\n" + o += " if (it != 0) {\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"{name}[{i}] != 0" for i in range(self.array_size)] @@ -1163,6 +1192,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : {name}) {{\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += "}" + return o + # For fixed arrays, we always encode all elements # Special case for single-element arrays - no loop needed @@ -1186,6 +1222,11 @@ class FixedArrayRepeatedType(TypeInfo): def get_estimated_size(self) -> int: # For fixed arrays, estimate underlying type size * array size underlying_size = self._ti.get_estimated_size() + if self.is_define: + # When using a define, we don't know the actual size so just guess 3 + # This is only used for documentation and never actually used since + # fixed arrays are only for SOURCE_SERVER (encode-only) messages + return underlying_size * 3 return underlying_size * self.array_size From 4f58e1c8b92f233af368f7d1698bcead16f9c8e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 20:26:22 -1000 Subject: [PATCH 20/60] [core] Convert entity vectors to static allocation for reduced memory usage (#10018) --- esphome/core/application.h | 150 ++++++++-------------------- esphome/core/component_iterator.cpp | 13 --- esphome/core/component_iterator.h | 16 ++- esphome/core/config.py | 4 +- esphome/core/defines.h | 23 +++++ esphome/core/helpers.h | 36 +++++++ 6 files changed, 118 insertions(+), 124 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a83789837f..b7824a254b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,69 +216,6 @@ class Application { /// Reserve space for components to avoid memory fragmentation void reserve_components(size_t count) { this->components_.reserve(count); } -#ifdef USE_BINARY_SENSOR - void reserve_binary_sensor(size_t count) { this->binary_sensors_.reserve(count); } -#endif -#ifdef USE_SWITCH - void reserve_switch(size_t count) { this->switches_.reserve(count); } -#endif -#ifdef USE_BUTTON - void reserve_button(size_t count) { this->buttons_.reserve(count); } -#endif -#ifdef USE_SENSOR - void reserve_sensor(size_t count) { this->sensors_.reserve(count); } -#endif -#ifdef USE_TEXT_SENSOR - void reserve_text_sensor(size_t count) { this->text_sensors_.reserve(count); } -#endif -#ifdef USE_FAN - void reserve_fan(size_t count) { this->fans_.reserve(count); } -#endif -#ifdef USE_COVER - void reserve_cover(size_t count) { this->covers_.reserve(count); } -#endif -#ifdef USE_CLIMATE - void reserve_climate(size_t count) { this->climates_.reserve(count); } -#endif -#ifdef USE_LIGHT - void reserve_light(size_t count) { this->lights_.reserve(count); } -#endif -#ifdef USE_NUMBER - void reserve_number(size_t count) { this->numbers_.reserve(count); } -#endif -#ifdef USE_DATETIME_DATE - void reserve_date(size_t count) { this->dates_.reserve(count); } -#endif -#ifdef USE_DATETIME_TIME - void reserve_time(size_t count) { this->times_.reserve(count); } -#endif -#ifdef USE_DATETIME_DATETIME - void reserve_datetime(size_t count) { this->datetimes_.reserve(count); } -#endif -#ifdef USE_SELECT - void reserve_select(size_t count) { this->selects_.reserve(count); } -#endif -#ifdef USE_TEXT - void reserve_text(size_t count) { this->texts_.reserve(count); } -#endif -#ifdef USE_LOCK - void reserve_lock(size_t count) { this->locks_.reserve(count); } -#endif -#ifdef USE_VALVE - void reserve_valve(size_t count) { this->valves_.reserve(count); } -#endif -#ifdef USE_MEDIA_PLAYER - void reserve_media_player(size_t count) { this->media_players_.reserve(count); } -#endif -#ifdef USE_ALARM_CONTROL_PANEL - void reserve_alarm_control_panel(size_t count) { this->alarm_control_panels_.reserve(count); } -#endif -#ifdef USE_EVENT - void reserve_event(size_t count) { this->events_.reserve(count); } -#endif -#ifdef USE_UPDATE - void reserve_update(size_t count) { this->updates_.reserve(count); } -#endif #ifdef USE_AREAS void reserve_area(size_t count) { this->areas_.reserve(count); } #endif @@ -394,92 +331,90 @@ class Application { const std::vector &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR - const std::vector &get_binary_sensors() { return this->binary_sensors_; } + auto &get_binary_sensors() const { return this->binary_sensors_; } GET_ENTITY_METHOD(binary_sensor::BinarySensor, binary_sensor, binary_sensors) #endif #ifdef USE_SWITCH - const std::vector &get_switches() { return this->switches_; } + auto &get_switches() const { return this->switches_; } GET_ENTITY_METHOD(switch_::Switch, switch, switches) #endif #ifdef USE_BUTTON - const std::vector &get_buttons() { return this->buttons_; } + auto &get_buttons() const { return this->buttons_; } GET_ENTITY_METHOD(button::Button, button, buttons) #endif #ifdef USE_SENSOR - const std::vector &get_sensors() { return this->sensors_; } + auto &get_sensors() const { return this->sensors_; } GET_ENTITY_METHOD(sensor::Sensor, sensor, sensors) #endif #ifdef USE_TEXT_SENSOR - const std::vector &get_text_sensors() { return this->text_sensors_; } + auto &get_text_sensors() const { return this->text_sensors_; } GET_ENTITY_METHOD(text_sensor::TextSensor, text_sensor, text_sensors) #endif #ifdef USE_FAN - const std::vector &get_fans() { return this->fans_; } + auto &get_fans() const { return this->fans_; } GET_ENTITY_METHOD(fan::Fan, fan, fans) #endif #ifdef USE_COVER - const std::vector &get_covers() { return this->covers_; } + auto &get_covers() const { return this->covers_; } GET_ENTITY_METHOD(cover::Cover, cover, covers) #endif #ifdef USE_LIGHT - const std::vector &get_lights() { return this->lights_; } + auto &get_lights() const { return this->lights_; } GET_ENTITY_METHOD(light::LightState, light, lights) #endif #ifdef USE_CLIMATE - const std::vector &get_climates() { return this->climates_; } + auto &get_climates() const { return this->climates_; } GET_ENTITY_METHOD(climate::Climate, climate, climates) #endif #ifdef USE_NUMBER - const std::vector &get_numbers() { return this->numbers_; } + auto &get_numbers() const { return this->numbers_; } GET_ENTITY_METHOD(number::Number, number, numbers) #endif #ifdef USE_DATETIME_DATE - const std::vector &get_dates() { return this->dates_; } + auto &get_dates() const { return this->dates_; } GET_ENTITY_METHOD(datetime::DateEntity, date, dates) #endif #ifdef USE_DATETIME_TIME - const std::vector &get_times() { return this->times_; } + auto &get_times() const { return this->times_; } GET_ENTITY_METHOD(datetime::TimeEntity, time, times) #endif #ifdef USE_DATETIME_DATETIME - const std::vector &get_datetimes() { return this->datetimes_; } + auto &get_datetimes() const { return this->datetimes_; } GET_ENTITY_METHOD(datetime::DateTimeEntity, datetime, datetimes) #endif #ifdef USE_TEXT - const std::vector &get_texts() { return this->texts_; } + auto &get_texts() const { return this->texts_; } GET_ENTITY_METHOD(text::Text, text, texts) #endif #ifdef USE_SELECT - const std::vector &get_selects() { return this->selects_; } + auto &get_selects() const { return this->selects_; } GET_ENTITY_METHOD(select::Select, select, selects) #endif #ifdef USE_LOCK - const std::vector &get_locks() { return this->locks_; } + auto &get_locks() const { return this->locks_; } GET_ENTITY_METHOD(lock::Lock, lock, locks) #endif #ifdef USE_VALVE - const std::vector &get_valves() { return this->valves_; } + auto &get_valves() const { return this->valves_; } GET_ENTITY_METHOD(valve::Valve, valve, valves) #endif #ifdef USE_MEDIA_PLAYER - const std::vector &get_media_players() { return this->media_players_; } + auto &get_media_players() const { return this->media_players_; } GET_ENTITY_METHOD(media_player::MediaPlayer, media_player, media_players) #endif #ifdef USE_ALARM_CONTROL_PANEL - const std::vector &get_alarm_control_panels() { - return this->alarm_control_panels_; - } + auto &get_alarm_control_panels() const { return this->alarm_control_panels_; } GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) #endif #ifdef USE_EVENT - const std::vector &get_events() { return this->events_; } + auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) #endif #ifdef USE_UPDATE - const std::vector &get_updates() { return this->updates_; } + auto &get_updates() const { return this->updates_; } GET_ENTITY_METHOD(update::UpdateEntity, update, updates) #endif @@ -558,67 +493,68 @@ class Application { std::vector areas_{}; #endif #ifdef USE_BINARY_SENSOR - std::vector binary_sensors_{}; + StaticVector binary_sensors_{}; #endif #ifdef USE_SWITCH - std::vector switches_{}; + StaticVector switches_{}; #endif #ifdef USE_BUTTON - std::vector buttons_{}; + StaticVector buttons_{}; #endif #ifdef USE_EVENT - std::vector events_{}; + StaticVector events_{}; #endif #ifdef USE_SENSOR - std::vector sensors_{}; + StaticVector sensors_{}; #endif #ifdef USE_TEXT_SENSOR - std::vector text_sensors_{}; + StaticVector text_sensors_{}; #endif #ifdef USE_FAN - std::vector fans_{}; + StaticVector fans_{}; #endif #ifdef USE_COVER - std::vector covers_{}; + StaticVector covers_{}; #endif #ifdef USE_CLIMATE - std::vector climates_{}; + StaticVector climates_{}; #endif #ifdef USE_LIGHT - std::vector lights_{}; + StaticVector lights_{}; #endif #ifdef USE_NUMBER - std::vector numbers_{}; + StaticVector numbers_{}; #endif #ifdef USE_DATETIME_DATE - std::vector dates_{}; + StaticVector dates_{}; #endif #ifdef USE_DATETIME_TIME - std::vector times_{}; + StaticVector times_{}; #endif #ifdef USE_DATETIME_DATETIME - std::vector datetimes_{}; + StaticVector datetimes_{}; #endif #ifdef USE_SELECT - std::vector selects_{}; + StaticVector selects_{}; #endif #ifdef USE_TEXT - std::vector texts_{}; + StaticVector texts_{}; #endif #ifdef USE_LOCK - std::vector locks_{}; + StaticVector locks_{}; #endif #ifdef USE_VALVE - std::vector valves_{}; + StaticVector valves_{}; #endif #ifdef USE_MEDIA_PLAYER - std::vector media_players_{}; + StaticVector media_players_{}; #endif #ifdef USE_ALARM_CONTROL_PANEL - std::vector alarm_control_panels_{}; + StaticVector + alarm_control_panels_{}; #endif #ifdef USE_UPDATE - std::vector updates_{}; + StaticVector updates_{}; #endif #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 1e8f670d8b..668c4a1fda 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -17,19 +17,6 @@ void ComponentIterator::begin(bool include_internal) { this->include_internal_ = include_internal; } -template -void ComponentIterator::process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)) { - if (this->at_ >= items.size()) { - this->advance_platform_(); - } else { - PlatformItem *item = items[this->at_]; - if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { - this->at_++; - } - } -} - void ComponentIterator::advance_platform_() { this->state_ = static_cast(static_cast(this->state_) + 1); this->at_ = 0; diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 7a9771b8f2..fdc30485bc 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -172,9 +172,19 @@ class ComponentIterator { uint16_t at_{0}; // Supports up to 65,535 entities per type bool include_internal_{false}; - template - void process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)); + template + void process_platform_item_(const Container &items, + bool (ComponentIterator::*on_item)(typename Container::value_type)) { + if (this->at_ >= items.size()) { + this->advance_platform_(); + } else { + typename Container::value_type item = items[this->at_]; + if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { + this->at_++; + } + } + } + void advance_platform_(); }; diff --git a/esphome/core/config.py b/esphome/core/config.py index 6d93117164..3bc030ad50 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -421,8 +421,10 @@ async def _add_automations(config): @coroutine_with_priority(-100.0) async def _add_platform_reserves() -> None: + # Generate compile-time entity count defines for static_entity_vector for platform_name, count in sorted(CORE.platform_counts.items()): - cg.add(cg.RawStatement(f"App.reserve_{platform_name}({count});"), prepend=True) + define_name = f"ESPHOME_ENTITY_{platform_name.upper()}_COUNT" + cg.add_define(define_name, count) @coroutine_with_priority(100.0) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 55652e443e..3ed0af91eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -239,3 +239,26 @@ // #define USE_BSEC2 // Requires a library with proprietary license #define USE_DASHBOARD_IMPORT + +// Default entity counts for static analysis +#define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 +#define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_BUTTON_COUNT 1 +#define ESPHOME_ENTITY_CLIMATE_COUNT 1 +#define ESPHOME_ENTITY_COVER_COUNT 1 +#define ESPHOME_ENTITY_DATE_COUNT 1 +#define ESPHOME_ENTITY_DATETIME_COUNT 1 +#define ESPHOME_ENTITY_EVENT_COUNT 1 +#define ESPHOME_ENTITY_FAN_COUNT 1 +#define ESPHOME_ENTITY_LIGHT_COUNT 1 +#define ESPHOME_ENTITY_LOCK_COUNT 1 +#define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1 +#define ESPHOME_ENTITY_NUMBER_COUNT 1 +#define ESPHOME_ENTITY_SELECT_COUNT 1 +#define ESPHOME_ENTITY_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_SWITCH_COUNT 1 +#define ESPHOME_ENTITY_TEXT_COUNT 1 +#define ESPHOME_ENTITY_TEXT_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_TIME_COUNT 1 +#define ESPHOME_ENTITY_UPDATE_COUNT 1 +#define ESPHOME_ENTITY_VALVE_COUNT 1 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5204804e1e..b05cc11029 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -91,6 +91,42 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); ///@} +/// @name Container utilities +///@{ + +/// Minimal static vector - saves memory by avoiding std::vector overhead +template class StaticVector { + public: + using value_type = T; + using iterator = typename std::array::iterator; + using const_iterator = typename std::array::const_iterator; + + private: + std::array data_{}; + size_t count_{0}; + + public: + // Minimal vector-compatible interface - only what we actually use + void push_back(const T &value) { + if (count_ < N) { + data_[count_++] = value; + } + } + + size_t size() const { return count_; } + + T &operator[](size_t i) { return data_[i]; } + const T &operator[](size_t i) const { return data_[i]; } + + // For range-based for loops + iterator begin() { return data_.begin(); } + iterator end() { return data_.begin() + count_; } + const_iterator begin() const { return data_.begin(); } + const_iterator end() const { return data_.begin() + count_; } +}; + +///@} + /// @name Mathematics ///@{ From fd442cc4856574b6b3d2ef00b72abfea81bb11e9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 Aug 2025 08:21:54 +1000 Subject: [PATCH 21/60] [syslog] Fix RFC3164 timestamp compliance for single-digit days (#10034) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/syslog/esphome_syslog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index e322a6951d..71468fa932 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -35,7 +35,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; } int pri = this->facility_ * 8 + severity; - auto timestamp = this->time_->now().strftime("%b %d %H:%M:%S"); + auto timestamp = this->time_->now().strftime("%b %e %H:%M:%S"); size_t len = message_len; // remove color formatting if (this->strip_ && message[0] == 0x1B && len > 11) { From 296442d8f16c83bc68fb1c71c3d5ed03be155d8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 13:59:20 -1000 Subject: [PATCH 22/60] [core] Fix compilation errors when platform sections have no entities (#10023) --- .../alarm_control_panel/__init__.py | 1 - esphome/components/binary_sensor/__init__.py | 1 - esphome/components/button/__init__.py | 1 - esphome/components/climate/__init__.py | 1 - esphome/components/cover/__init__.py | 1 - esphome/components/datetime/__init__.py | 2 -- esphome/components/event/__init__.py | 1 - esphome/components/fan/__init__.py | 1 - esphome/components/light/__init__.py | 1 - esphome/components/lock/__init__.py | 1 - esphome/components/number/__init__.py | 1 - esphome/components/select/__init__.py | 1 - esphome/components/sensor/__init__.py | 1 - esphome/components/switch/__init__.py | 1 - esphome/components/text/__init__.py | 1 - esphome/components/text_sensor/__init__.py | 1 - esphome/components/update/__init__.py | 1 - esphome/components/valve/__init__.py | 1 - esphome/core/config.py | 21 ++++++++++++++++--- 19 files changed, 18 insertions(+), 22 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b076175eb8..058e061d1e 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -348,4 +348,3 @@ async def alarm_control_panel_is_armed_to_code( @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(alarm_control_panel_ns.using) - cg.add_define("USE_ALARM_CONTROL_PANEL") diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 376a399637..b56fde1ffd 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -654,7 +654,6 @@ async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args) @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_BINARY_SENSOR") cg.add_global(binary_sensor_ns.using) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index ed2670a5c5..a23958989e 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -137,4 +137,3 @@ async def button_press_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(button_ns.using) - cg.add_define("USE_BUTTON") diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 9530ecdcca..4af3a619b5 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -519,5 +519,4 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_CLIMATE") cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index cd97a38ecc..0e01eb336f 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -265,5 +265,4 @@ async def cover_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_COVER") cg.add_global(cover_ns.using) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 4788810965..1d84b75f26 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -164,7 +164,6 @@ async def register_datetime(var, config): cg.add(getattr(cg.App, f"register_{entity_type}")(var)) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) - cg.add_define(f"USE_DATETIME_{config[CONF_TYPE]}") async def new_datetime(config, *args): @@ -175,7 +174,6 @@ async def new_datetime(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_DATETIME") cg.add_global(datetime_ns.using) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 3aff96a48e..1948570ecd 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -145,5 +145,4 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_EVENT") cg.add_global(event_ns.using) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 0b1d39575d..3fb217a24e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -400,5 +400,4 @@ async def fan_is_on_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_FAN") cg.add_global(fan_ns.using) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7ab899edb2..fa39721ee2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -285,5 +285,4 @@ async def new_light(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_LIGHT") cg.add_global(light_ns.using) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index e62d9f3e2b..7977efd264 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -158,4 +158,3 @@ async def lock_is_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(lock_ns.using) - cg.add_define("USE_LOCK") diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 90a1619e4c..4a83d5fc5f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -323,7 +323,6 @@ async def number_in_range_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_NUMBER") cg.add_global(number_ns.using) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ed1f6c020d..dd3feccab5 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -126,7 +126,6 @@ async def new_select(config, *, options: list[str]): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_SELECT") cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 23e6ad0f2c..2275027004 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1139,5 +1139,4 @@ def _lstsq(a, b): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_SENSOR") cg.add_global(sensor_ns.using) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c09675069f..a595d43445 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -202,4 +202,3 @@ async def switch_is_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(switch_ns.using) - cg.add_define("USE_SWITCH") diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 8362e09ac0..aa831d1f06 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -151,7 +151,6 @@ async def new_text( @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_TEXT") cg.add_global(text_ns.using) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 0341ab2f71..e4aa701a7b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -232,7 +232,6 @@ async def new_text_sensor(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_TEXT_SENSOR") cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 758267f412..50d8aaf139 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -126,7 +126,6 @@ async def new_update(config): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_UPDATE") cg.add_global(update_ns.using) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index cb27546120..53254068af 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -235,5 +235,4 @@ async def valve_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_VALVE") cg.add_global(valve_ns.using) diff --git a/esphome/core/config.py b/esphome/core/config.py index 3bc030ad50..6a87bab730 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -419,13 +419,28 @@ async def _add_automations(config): await automation.build_automation(trigger, [], conf) +# Datetime component has special subtypes that need additional defines +DATETIME_SUBTYPES = {"date", "time", "datetime"} + + @coroutine_with_priority(-100.0) -async def _add_platform_reserves() -> None: - # Generate compile-time entity count defines for static_entity_vector +async def _add_platform_defines() -> None: + # Generate compile-time defines for platforms that have actual entities + # Only add USE_* and count defines when there are entities for platform_name, count in sorted(CORE.platform_counts.items()): + if count <= 0: + continue + define_name = f"ESPHOME_ENTITY_{platform_name.upper()}_COUNT" cg.add_define(define_name, count) + # Datetime subtypes only use USE_DATETIME_* defines + if platform_name in DATETIME_SUBTYPES: + cg.add_define(f"USE_DATETIME_{platform_name.upper()}") + else: + # Regular platforms use USE_* defines + cg.add_define(f"USE_{platform_name.upper()}") + @coroutine_with_priority(100.0) async def to_code(config: ConfigType) -> None: @@ -449,7 +464,7 @@ async def to_code(config: ConfigType) -> None: cg.RawStatement(f"App.reserve_components({len(CORE.component_ids)});"), ) - CORE.add_job(_add_platform_reserves) + CORE.add_job(_add_platform_defines) CORE.add_job(_add_automations, config) From b1b0638fabef80ac5dc2186e0be8f600018eafba Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 3 Aug 2025 18:35:52 +1000 Subject: [PATCH 23/60] [config] Fix reversion of excessive yaml output after error (#10043) Co-authored-by: J. Nick Koston --- esphome/log.py | 2 + tests/unit_tests/test_log.py | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/unit_tests/test_log.py diff --git a/esphome/log.py b/esphome/log.py index 8831b1b2b3..bfd1875b55 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -37,6 +37,8 @@ class AnsiStyle(Enum): def color(col: AnsiFore, msg: str, reset: bool = True) -> str: + if col == AnsiFore.KEEP: + return msg s = col.value + msg if reset and col: s += AnsiStyle.RESET_ALL.value diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py new file mode 100644 index 0000000000..02798f1029 --- /dev/null +++ b/tests/unit_tests/test_log.py @@ -0,0 +1,80 @@ +import pytest + +from esphome.log import AnsiFore, AnsiStyle, color + + +def test_color_keep_returns_unchanged_message() -> None: + """Test that AnsiFore.KEEP returns the message unchanged.""" + msg = "test message" + result = color(AnsiFore.KEEP, msg) + assert result == msg + + +def test_color_keep_ignores_reset_parameter() -> None: + """Test that reset parameter is ignored when using AnsiFore.KEEP.""" + msg = "test message" + result_with_reset = color(AnsiFore.KEEP, msg, reset=True) + result_without_reset = color(AnsiFore.KEEP, msg, reset=False) + assert result_with_reset == msg + assert result_without_reset == msg + + +def test_color_applies_color_code() -> None: + """Test that color codes are properly applied to messages.""" + msg = "test message" + result = color(AnsiFore.RED, msg, reset=False) + assert result == f"{AnsiFore.RED.value}{msg}" + + +def test_color_applies_reset_when_requested() -> None: + """Test that RESET_ALL is added when reset=True.""" + msg = "test message" + result = color(AnsiFore.GREEN, msg, reset=True) + expected = f"{AnsiFore.GREEN.value}{msg}{AnsiStyle.RESET_ALL.value}" + assert result == expected + + +def test_color_no_reset_when_not_requested() -> None: + """Test that RESET_ALL is not added when reset=False.""" + msg = "test message" + result = color(AnsiFore.BLUE, msg, reset=False) + expected = f"{AnsiFore.BLUE.value}{msg}" + assert result == expected + + +def test_color_with_empty_message() -> None: + """Test color function with empty message.""" + result = color(AnsiFore.YELLOW, "", reset=True) + expected = f"{AnsiFore.YELLOW.value}{AnsiStyle.RESET_ALL.value}" + assert result == expected + + +@pytest.mark.parametrize( + "col", + [ + AnsiFore.BLACK, + AnsiFore.RED, + AnsiFore.GREEN, + AnsiFore.YELLOW, + AnsiFore.BLUE, + AnsiFore.MAGENTA, + AnsiFore.CYAN, + AnsiFore.WHITE, + AnsiFore.RESET, + ], +) +def test_all_ansi_colors(col: AnsiFore) -> None: + """Test that all AnsiFore colors work correctly.""" + msg = "test" + result = color(col, msg, reset=True) + expected = f"{col.value}{msg}{AnsiStyle.RESET_ALL.value}" + assert result == expected + + +def test_ansi_fore_keep_is_enum_member() -> None: + """Ensure AnsiFore.KEEP is an Enum member and evaluates to truthy.""" + assert isinstance(AnsiFore.KEEP, AnsiFore) + # Enum members are truthy, even with empty string values + assert bool(AnsiFore.KEEP) is True + # But the value itself is still an empty string + assert AnsiFore.KEEP.value == "" From d69e98e15d1d250ffac6be0a130cd7fbb741efe1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 14:23:45 -1000 Subject: [PATCH 24/60] [api] Fix OTA progress updates not being sent when main loop is blocked (#10049) --- esphome/components/api/api_connection.h | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 21688e601c..f0f308c248 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -703,10 +703,16 @@ class APIConnection : public APIServerConnection { bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { // Try to send immediately if: - // 1. We should try to send immediately (should_try_send_immediately = true) - // 2. Batch delay is 0 (user has opted in to immediate sending) - // 3. Buffer has space available - if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. OR: We should try to send immediately (should_try_send_immediately = true) + // AND Batch delay is 0 (user has opted in to immediate sending) + // 3. AND: Buffer has space available + if (( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && From 339c26c815f58e8702981b09fe471e2edded44b8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:51:34 +1000 Subject: [PATCH 25/60] [color][lvgl] Allow Color to be used for lv_color_t (#10016) --- esphome/core/color.h | 10 ++++++++++ tests/components/lvgl/lvgl-package.yaml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/core/color.h b/esphome/core/color.h index 2b307bb438..5dce58a485 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -1,8 +1,13 @@ #pragma once +#include "defines.h" #include "component.h" #include "helpers.h" +#ifdef USE_LVGL +#include "esphome/components/lvgl/lvgl_proxy.h" +#endif // USE_LVGL + namespace esphome { inline static constexpr uint8_t esp_scale8(uint8_t i, uint8_t scale) { @@ -33,6 +38,11 @@ struct Color { uint32_t raw_32; }; +#ifdef USE_LVGL + // convenience function for Color to get a lv_color_t representation + operator lv_color_t() const { return lv_color_make(this->r, this->g, this->b); } +#endif + inline constexpr Color() ESPHOME_ALWAYS_INLINE : raw_32(0) {} // NOLINT inline constexpr Color(uint8_t red, uint8_t green, uint8_t blue) ESPHOME_ALWAYS_INLINE : r(red), g(green), diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 853466c9cc..7cd2e2b93e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -78,7 +78,7 @@ lvgl: - id: date_style text_font: roboto10 align: center - text_color: color_id2 + text_color: !lambda return color_id2; bg_opa: cover radius: 4 pad_all: 2 From 0f13af007654d3412fe1f3c63e86c957c2bb0d9f Mon Sep 17 00:00:00 2001 From: "@RubenKelevra" Date: Mon, 4 Aug 2025 03:08:11 +0200 Subject: [PATCH 26/60] Update esp32-camera library version to 2.1.0 (#9527) --- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 43e71df432..bfb66ff83a 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -345,7 +345,7 @@ async def to_code(config): cg.add_define("USE_CAMERA") if CORE.using_esp_idf: - add_idf_component(name="espressif/esp32-camera", ref="2.0.15") + add_idf_component(name="espressif/esp32-camera", ref="2.1.0") for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c43b622684..419a9797e3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.0.15 + version: 2.1.0 espressif/mdns: version: 1.8.2 espressif/esp_wifi_remote: From b44d2183aa3790b54be9282248226b3b4ff8eb82 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 01:19:12 +0000 Subject: [PATCH 27/60] Bump aioesphomeapi from 37.2.3 to 37.2.4 (#10050) 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 bfdb08323e..6f79e86bdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.3 +aioesphomeapi==37.2.4 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From dbaf2cdd5030de58b495236e940e00d2f6a13feb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:46:06 -1000 Subject: [PATCH 28/60] [core] Replace std::find and std::max_element with simple loops to reduce binary size (#10044) --- esphome/core/application.cpp | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 3ac17849dd..0467b0b57f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -459,24 +459,25 @@ void Application::unregister_socket_fd(int fd) { if (fd < 0) return; - auto it = std::find(this->socket_fds_.begin(), this->socket_fds_.end(), fd); - if (it != this->socket_fds_.end()) { + for (size_t i = 0; i < this->socket_fds_.size(); i++) { + if (this->socket_fds_[i] != fd) + continue; + // Swap with last element and pop - O(1) removal since order doesn't matter - if (it != this->socket_fds_.end() - 1) { - std::swap(*it, this->socket_fds_.back()); - } + if (i < this->socket_fds_.size() - 1) + this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { - if (this->socket_fds_.empty()) { - this->max_fd_ = -1; - } else { - // Find new max using std::max_element - this->max_fd_ = *std::max_element(this->socket_fds_.begin(), this->socket_fds_.end()); + this->max_fd_ = -1; + for (int sock_fd : this->socket_fds_) { + if (sock_fd > this->max_fd_) + this->max_fd_ = sock_fd; } } + return; } } From d86e1e29a9af9063429d25629cf872d1a682d9b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:51:50 -1000 Subject: [PATCH 29/60] [core] Convert components, devices, and areas vectors to static allocation (#10020) --- esphome/core/application.h | 100 +++++++++++++++++-------------------- esphome/core/config.py | 12 ++--- esphome/core/defines.h | 5 +- esphome/core/helpers.h | 10 ++++ 4 files changed, 66 insertions(+), 61 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b7824a254b..4eb4984f71 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -214,14 +214,6 @@ class Application { #endif /// Reserve space for components to avoid memory fragmentation - void reserve_components(size_t count) { this->components_.reserve(count); } - -#ifdef USE_AREAS - void reserve_area(size_t count) { this->areas_.reserve(count); } -#endif -#ifdef USE_DEVICES - void reserve_device(size_t count) { this->devices_.reserve(count); } -#endif /// Register the component in this Application instance. template C *register_component(C *c) { @@ -316,7 +308,7 @@ class Application { } \ return nullptr; \ } - const std::vector &get_devices() { return this->devices_; } + const auto &get_devices() { return this->devices_; } #else #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ @@ -328,7 +320,7 @@ class Application { } #endif // USE_DEVICES #ifdef USE_AREAS - const std::vector &get_areas() { return this->areas_; } + const auto &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR auto &get_binary_sensors() const { return this->binary_sensors_; } @@ -462,12 +454,7 @@ class Application { const char *comment_{nullptr}; const char *compilation_time_{nullptr}; - // size_t members - size_t dump_config_at_{SIZE_MAX}; - - // Vectors (largest members) - std::vector components_{}; - + // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components // ================================================= // Components are partitioned into [active | inactive] sections: @@ -485,12 +472,54 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop std::vector looping_components_{}; +#ifdef USE_SOCKET_SELECT_SUPPORT + std::vector socket_fds_; // Vector of all monitored socket file descriptors +#endif + + // std::string members (typically 24-32 bytes each) + std::string name_; + std::string friendly_name_; + + // size_t members + size_t dump_config_at_{SIZE_MAX}; + + // 4-byte members + uint32_t last_loop_{0}; + uint32_t loop_component_start_time_{0}; + +#ifdef USE_SOCKET_SELECT_SUPPORT + int max_fd_{-1}; // Highest file descriptor number for select() +#endif + + // 2-byte members (grouped together for alignment) + uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) + uint16_t looping_components_active_end_{0}; // Index marking end of active components in looping_components_ + uint16_t current_loop_index_{0}; // For safe reentrant modifications during iteration + + // 1-byte members (grouped together to minimize padding) + uint8_t app_state_{0}; + bool name_add_mac_suffix_; + bool in_loop_{false}; + volatile bool has_pending_enable_loop_requests_{false}; + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes +#endif + +#ifdef USE_SOCKET_SELECT_SUPPORT + // Variable-sized members + fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes + fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ +#endif + + // StaticVectors (largest members - contain actual array data inline) + StaticVector components_{}; #ifdef USE_DEVICES - std::vector devices_{}; + StaticVector devices_{}; #endif #ifdef USE_AREAS - std::vector areas_{}; + StaticVector areas_{}; #endif #ifdef USE_BINARY_SENSOR StaticVector binary_sensors_{}; @@ -556,41 +585,6 @@ class Application { #ifdef USE_UPDATE StaticVector updates_{}; #endif - -#ifdef USE_SOCKET_SELECT_SUPPORT - std::vector socket_fds_; // Vector of all monitored socket file descriptors -#endif - - // String members - std::string name_; - std::string friendly_name_; - - // 4-byte members - uint32_t last_loop_{0}; - uint32_t loop_component_start_time_{0}; - -#ifdef USE_SOCKET_SELECT_SUPPORT - int max_fd_{-1}; // Highest file descriptor number for select() -#endif - - // 2-byte members (grouped together for alignment) - uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) - uint16_t looping_components_active_end_{0}; - uint16_t current_loop_index_{0}; // For safe reentrant modifications during iteration - - // 1-byte members (grouped together to minimize padding) - uint8_t app_state_{0}; - bool name_add_mac_suffix_; - bool in_loop_{false}; - volatile bool has_pending_enable_loop_requests_{false}; - -#ifdef USE_SOCKET_SELECT_SUPPORT - bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes - - // Variable-sized members at end - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes - fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ -#endif }; /// Global storage of Application pointer - only one Application can exist. diff --git a/esphome/core/config.py b/esphome/core/config.py index 6a87bab730..90768a4b09 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -459,10 +459,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_NAME_ADD_MAC_SUFFIX], ) ) - # Reserve space for components to avoid reallocation during registration - cg.add( - cg.RawStatement(f"App.reserve_components({len(CORE.component_ids)});"), - ) + # Define component count for static allocation + cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids)) CORE.add_job(_add_platform_defines) @@ -531,8 +529,8 @@ async def to_code(config: ConfigType) -> None: all_areas.extend(config[CONF_AREAS]) if all_areas: - cg.add(cg.RawStatement(f"App.reserve_area({len(all_areas)});")) cg.add_define("USE_AREAS") + cg.add_define("ESPHOME_AREA_COUNT", len(all_areas)) for area_conf in all_areas: area_id: core.ID = area_conf[CONF_ID] @@ -549,9 +547,9 @@ async def to_code(config: ConfigType) -> None: if not devices: return - # Reserve space for devices - cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) + # Define device count for static allocation cg.add_define("USE_DEVICES") + cg.add_define("ESPHOME_DEVICE_COUNT", len(devices)) # Process each device for dev_conf in devices: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3ed0af91eb..996dbc7e8d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,7 +240,10 @@ #define USE_DASHBOARD_IMPORT -// Default entity counts for static analysis +// Default counts for static analysis +#define ESPHOME_COMPONENT_COUNT 50 +#define ESPHOME_DEVICE_COUNT 10 +#define ESPHOME_AREA_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 #define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 #define ESPHOME_ENTITY_BUTTON_COUNT 1 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b05cc11029..b5fe59c4fd 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -100,6 +101,8 @@ template class StaticVector { using value_type = T; using iterator = typename std::array::iterator; using const_iterator = typename std::array::const_iterator; + using reverse_iterator = std::reverse_iterator; + using const_reverse_iterator = std::reverse_iterator; private: std::array data_{}; @@ -114,6 +117,7 @@ template class StaticVector { } size_t size() const { return count_; } + bool empty() const { return count_ == 0; } T &operator[](size_t i) { return data_[i]; } const T &operator[](size_t i) const { return data_[i]; } @@ -123,6 +127,12 @@ template class StaticVector { iterator end() { return data_.begin() + count_; } const_iterator begin() const { return data_.begin(); } const_iterator end() const { return data_.begin() + count_; } + + // Reverse iterators + reverse_iterator rbegin() { return reverse_iterator(end()); } + reverse_iterator rend() { return reverse_iterator(begin()); } + const_reverse_iterator rbegin() const { return const_reverse_iterator(end()); } + const_reverse_iterator rend() const { return const_reverse_iterator(begin()); } }; ///@} From cd6cf074d9b6fdcc9b06c7e309f8b1d2ac11dda2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:56:06 -1000 Subject: [PATCH 30/60] [core] Replace std::stable_sort with insertion sort to save 3.5KB flash (#10035) --- esphome/core/application.cpp | 48 ++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0467b0b57f..73bf13ab7c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -34,6 +34,44 @@ namespace esphome { static const char *const TAG = "app"; +// Helper function for insertion sort of components by setup priority +// Using insertion sort instead of std::stable_sort saves ~1.3KB of flash +// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +// IMPORTANT: This sort is stable (preserves relative order of equal elements), +// which is necessary to maintain user-defined component order for same priority +template static void insertion_sort_by_setup_priority(Iterator first, Iterator last) { + for (auto it = first + 1; it != last; ++it) { + auto key = *it; + float key_priority = key->get_actual_setup_priority(); + auto j = it - 1; + + // Using '<' (not '<=') ensures stability - equal priority components keep their order + while (j >= first && (*j)->get_actual_setup_priority() < key_priority) { + *(j + 1) = *j; + j--; + } + *(j + 1) = key; + } +} + +// Helper function for insertion sort of components by loop priority +// IMPORTANT: This sort is stable (preserves relative order of equal elements), +// which is required when components are re-sorted during setup() if they block +template static void insertion_sort_by_loop_priority(Iterator first, Iterator last) { + for (auto it = first + 1; it != last; ++it) { + auto key = *it; + float key_priority = key->get_loop_priority(); + auto j = it - 1; + + // Using '<' (not '<=') ensures stability - equal priority components keep their order + while (j >= first && (*j)->get_loop_priority() < key_priority) { + *(j + 1) = *j; + j--; + } + *(j + 1) = key; + } +} + void Application::register_component_(Component *comp) { if (comp == nullptr) { ESP_LOGW(TAG, "Tried to register null component!"); @@ -51,9 +89,9 @@ void Application::register_component_(Component *comp) { void Application::setup() { ESP_LOGI(TAG, "Running through setup()"); ESP_LOGV(TAG, "Sorting components by setup priority"); - std::stable_sort(this->components_.begin(), this->components_.end(), [](const Component *a, const Component *b) { - return a->get_actual_setup_priority() > b->get_actual_setup_priority(); - }); + + // Sort by setup priority using our helper function + insertion_sort_by_setup_priority(this->components_.begin(), this->components_.end()); // Initialize looping_components_ early so enable_pending_loops_() works during setup this->calculate_looping_components_(); @@ -69,8 +107,8 @@ void Application::setup() { if (component->can_proceed()) continue; - std::stable_sort(this->components_.begin(), this->components_.begin() + i + 1, - [](Component *a, Component *b) { return a->get_loop_priority() > b->get_loop_priority(); }); + // Sort components 0 through i by loop priority + insertion_sort_by_loop_priority(this->components_.begin(), this->components_.begin() + i + 1); do { uint8_t new_app_state = STATUS_LED_WARNING; From 3fbbdb4589ef306aeac29ea672320860ad15821d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:00:56 -1000 Subject: [PATCH 31/60] [web_server_idf] Replace std::find_if with simple loop to reduce binary size (#10042) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/web_server_idf/web_server_idf.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 734259093e..483fae4f08 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -423,14 +423,14 @@ void AsyncEventSourceResponse::destroy(void *ptr) { void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) { DeferredEvent item(source, message_generator); - auto iter = std::find_if(this->deferred_queue_.begin(), this->deferred_queue_.end(), - [&item](const DeferredEvent &test) -> bool { return test == item; }); - - if (iter != this->deferred_queue_.end()) { - (*iter) = item; - } else { - this->deferred_queue_.push_back(item); + // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size + for (auto &event : this->deferred_queue_) { + if (event == item) { + event = item; + return; + } } + this->deferred_queue_.push_back(item); } void AsyncEventSourceResponse::process_deferred_queue_() { From c9d865a0610e5023dbd5a6d9b85068b580a3c3ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:02:10 -1000 Subject: [PATCH 32/60] [core] Optimize Application::pre_setup() to reduce duplicate MAC address operations (#10039) --- esphome/core/application.h | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 4eb4984f71..4120afff53 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -101,12 +101,9 @@ class Application { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { - this->name_ = name + "-" + get_mac_address().substr(6); - if (friendly_name.empty()) { - this->friendly_name_ = ""; - } else { - this->friendly_name_ = friendly_name + " " + get_mac_address().substr(6); - } + const std::string mac_suffix = get_mac_address().substr(6); + this->name_ = name + "-" + mac_suffix; + this->friendly_name_ = friendly_name.empty() ? "" : friendly_name + " " + mac_suffix; } else { this->name_ = name; this->friendly_name_ = friendly_name; From a75f73dbf0efec8d2ab886e5dbabb2931721ba10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:03:37 -1000 Subject: [PATCH 33/60] [web_server] Reduce binary size by using EntityBase and minimizing template instantiations (#10033) --- esphome/components/web_server/web_server.cpp | 37 ++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 880145a2a1..a8d94d80da 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -376,23 +376,32 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { } #endif -#define set_json_id(root, obj, sensor, start_config) \ - (root)["id"] = sensor; \ - if (((start_config) == DETAIL_ALL)) { \ - (root)["name"] = (obj)->get_name(); \ - (root)["icon"] = (obj)->get_icon(); \ - (root)["entity_category"] = (obj)->get_entity_category(); \ - if ((obj)->is_disabled_by_default()) \ - (root)["is_disabled_by_default"] = (obj)->is_disabled_by_default(); \ +// Helper functions to reduce code size by avoiding macro expansion +static void set_json_id(JsonObject &root, EntityBase *obj, const std::string &id, JsonDetail start_config) { + root["id"] = id; + if (start_config == DETAIL_ALL) { + root["name"] = obj->get_name(); + root["icon"] = obj->get_icon(); + root["entity_category"] = obj->get_entity_category(); + bool is_disabled = obj->is_disabled_by_default(); + if (is_disabled) + root["is_disabled_by_default"] = is_disabled; } +} -#define set_json_value(root, obj, sensor, value, start_config) \ - set_json_id((root), (obj), sensor, start_config); \ - (root)["value"] = value; +template +static void set_json_value(JsonObject &root, EntityBase *obj, const std::string &id, const T &value, + JsonDetail start_config) { + set_json_id(root, obj, id, start_config); + root["value"] = value; +} -#define set_json_icon_state_value(root, obj, sensor, state, value, start_config) \ - set_json_value(root, obj, sensor, value, start_config); \ - (root)["state"] = state; +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const std::string &id, + const std::string &state, const T &value, JsonDetail start_config) { + set_json_value(root, obj, id, value, start_config); + root["state"] = state; +} // Helper to get request detail parameter static JsonDetail get_request_detail(AsyncWebServerRequest *request) { From 494a1a216c4b7ec77fb76745320d74ee32055bd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:09:12 -1000 Subject: [PATCH 34/60] [web_server] Conditionally compile authentication code to save flash memory (#10022) --- esphome/components/web_server/__init__.py | 1 + esphome/components/web_server_base/web_server_base.cpp | 2 ++ esphome/components/web_server_base/web_server_base.h | 6 ++++++ esphome/components/web_server_idf/web_server_idf.cpp | 2 ++ esphome/components/web_server_idf/web_server_idf.h | 2 ++ esphome/core/defines.h | 3 +++ tests/components/web_server/test.esp32-idf.yaml | 5 +++++ 7 files changed, 21 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 8ead14dcac..695757e137 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -298,6 +298,7 @@ async def to_code(config): if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if CONF_AUTH in config: + cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index e1c2bc0b25..6e7097338c 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,9 +14,11 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { // remove all handlers +#ifdef USE_WEBSERVER_AUTH if (!credentials_.username.empty()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } +#endif this->handlers_.push_back(handler); if (this->server_ != nullptr) { this->server_->addHandler(handler); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index a475238a37..cfca776ee1 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -41,6 +41,7 @@ class MiddlewareHandler : public AsyncWebHandler { AsyncWebHandler *next_; }; +#ifdef USE_WEBSERVER_AUTH struct Credentials { std::string username; std::string password; @@ -79,6 +80,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { protected: Credentials *credentials_; }; +#endif } // namespace internal @@ -108,8 +110,10 @@ class WebServerBase : public Component { std::shared_ptr get_server() const { return server_; } float get_setup_priority() const override; +#ifdef USE_WEBSERVER_AUTH void set_auth_username(std::string auth_username) { credentials_.username = std::move(auth_username); } void set_auth_password(std::string auth_password) { credentials_.password = std::move(auth_password); } +#endif void add_handler(AsyncWebHandler *handler); @@ -121,7 +125,9 @@ class WebServerBase : public Component { uint16_t port_{80}; std::shared_ptr server_{nullptr}; std::vector handlers_; +#ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; +#endif }; } // namespace web_server_base diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 483fae4f08..40fb015b99 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -223,6 +223,7 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code this->rsp_ = rsp; } +#ifdef USE_WEBSERVER_AUTH bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -261,6 +262,7 @@ void AsyncWebServerRequest::requestAuthentication(const char *realm) const { httpd_resp_set_hdr(*this, "WWW-Authenticate", auth_val.c_str()); httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } +#endif AsyncWebParameter *AsyncWebServerRequest::getParam(const std::string &name) { auto find = this->params_.find(name); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index e8e40ef9b0..76540ef232 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -115,9 +115,11 @@ class AsyncWebServerRequest { // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } +#ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) void requestAuthentication(const char *realm = nullptr) const; +#endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 996dbc7e8d..56de0127a6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -163,6 +163,7 @@ #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_SORTING @@ -210,6 +211,7 @@ {} #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -226,6 +228,7 @@ #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_SOCKET_SELECT_SUPPORT #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT #endif diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 7e6658e20e..24b292d0d6 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1 +1,6 @@ <<: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password From 9aad0733efa87a2e5c9bca42cbf48e899aefb76d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:14:17 -1000 Subject: [PATCH 35/60] [core] Update to esptool 5.0+ command syntax (#10011) --- esphome/__main__.py | 10 +++++----- esphome/components/esp32/post_build.py.script | 6 +++--- esphome/platformio_api.py | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 341c1fa893..5e45b7f213 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -277,20 +277,20 @@ def upload_using_esptool(config, port, file, speed): def run_esptool(baud_rate): cmd = [ - "esptool.py", + "esptool", "--before", - "default_reset", + "default-reset", "--after", - "hard_reset", + "hard-reset", "--baud", str(baud_rate), "--port", port, "--chip", mcu, - "write_flash", + "write-flash", "-z", - "--flash_size", + "--flash-size", "detect", ] for img in flash_images: diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 586f12e00b..c995214232 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -93,8 +93,8 @@ def merge_factory_bin(source, target, env): "esptool", "--chip", chip, - "merge_bin", - "--flash_size", + "merge-bin", + "--flash-size", flash_size, "--output", str(output_path), @@ -110,7 +110,7 @@ def merge_factory_bin(source, target, env): if result == 0: print(f"Successfully created {output_path}") else: - print(f"Error: esptool merge_bin failed with code {result}") + print(f"Error: esptool merge-bin failed with code {result}") def esp32_copy_ota_bin(source, target, env): diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 7415ec9794..21124fc859 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -61,6 +61,7 @@ FILTER_PLATFORMIO_LINES = [ r"Advanced Memory Usage is available via .*", r"Merged .* ELF section", r"esptool.py v.*", + r"esptool v.*", r"Checking size .*", r"Retrieving maximum program size .*", r"PLATFORM: .*", From ef372eeeb7f6b22bfa8fe7a42e387e56ac0ecaf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:19:24 -1000 Subject: [PATCH 36/60] [wifi] Replace std::stable_sort with insertion sort to save 2.4KB flash (#10037) --- esphome/components/wifi/wifi_component.cpp | 74 +++++++++++++++------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 98f75894f4..f815ab73c2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -505,6 +505,54 @@ void WiFiComponent::start_scanning() { this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; } +// Helper function for WiFi scan result comparison +// Returns true if 'a' should be placed before 'b' in the sorted order +[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { + // Matching networks always come before non-matching + if (a.get_matches() && !b.get_matches()) + return true; + if (!a.get_matches() && b.get_matches()) + return false; + + if (a.get_matches() && b.get_matches()) { + // For APs with the same SSID, always prefer stronger signal + // This helps with mesh networks and multiple APs + if (a.get_ssid() == b.get_ssid()) { + return a.get_rssi() > b.get_rssi(); + } + + // For different SSIDs, check priority first + if (a.get_priority() != b.get_priority()) + return a.get_priority() > b.get_priority(); + // If priorities are equal, prefer stronger signal + return a.get_rssi() > b.get_rssi(); + } + + // Both don't match - sort by signal strength + return a.get_rssi() > b.get_rssi(); +} + +// Helper function for insertion sort of WiFi scan results +// Using insertion sort instead of std::stable_sort saves flash memory +// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +// IMPORTANT: This sort is stable (preserves relative order of equal elements) +static void insertion_sort_scan_results(std::vector &results) { + const size_t size = results.size(); + for (size_t i = 1; i < size; i++) { + // Make a copy to avoid issues with move semantics during comparison + WiFiScanResult key = results[i]; + int32_t j = i - 1; + + // Move elements that are worse than key to the right + // For stability, we only move if key is strictly better than results[j] + while (j >= 0 && wifi_scan_result_is_better(key, results[j])) { + results[j + 1] = results[j]; + j--; + } + results[j + 1] = key; + } +} + void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { if (millis() - this->action_started_ > 30000) { @@ -535,30 +583,8 @@ void WiFiComponent::check_scanning_finished() { } } - std::stable_sort(this->scan_result_.begin(), this->scan_result_.end(), - [](const WiFiScanResult &a, const WiFiScanResult &b) { - // return true if a is better than b - if (a.get_matches() && !b.get_matches()) - return true; - if (!a.get_matches() && b.get_matches()) - return false; - - if (a.get_matches() && b.get_matches()) { - // For APs with the same SSID, always prefer stronger signal - // This helps with mesh networks and multiple APs - if (a.get_ssid() == b.get_ssid()) { - return a.get_rssi() > b.get_rssi(); - } - - // For different SSIDs, check priority first - if (a.get_priority() != b.get_priority()) - return a.get_priority() > b.get_priority(); - // If priorities are equal, prefer stronger signal - return a.get_rssi() > b.get_rssi(); - } - - return a.get_rssi() > b.get_rssi(); - }); + // Sort scan results using insertion sort for better memory efficiency + insertion_sort_scan_results(this->scan_result_); for (auto &res : this->scan_result_) { char bssid_s[18]; From 6a5eb460efef63c2fa91c2ba1df0c53f245ef4e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:27:05 -1000 Subject: [PATCH 37/60] [esp32] Add framework migration warning for upcoming ESP-IDF default change (#10030) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 62 ++++++++++++++++++++++++++++ esphome/util.py | 8 +++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 05a79553a4..c43cafc100 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -680,6 +680,64 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( ) +class _FrameworkMigrationWarning: + shown = False + + +def _show_framework_migration_message(name: str, variant: str) -> None: + """Show a friendly message about framework migration when defaulting to Arduino.""" + if _FrameworkMigrationWarning.shown: + return + _FrameworkMigrationWarning.shown = True + + from esphome.log import AnsiFore, color + + message = ( + color( + AnsiFore.BOLD_CYAN, + f"💡 IMPORTANT: {name} doesn't have a framework specified!", + ) + + "\n\n" + + f"Currently, {variant} defaults to the Arduino framework.\n" + + color(AnsiFore.YELLOW, "This will change to ESP-IDF in ESPHome 2026.1.0.\n") + + "\n" + + "Note: Newer ESP32 variants (C6, H2, P4, etc.) already use ESP-IDF by default.\n" + + "\n" + + "Why change? ESP-IDF offers:\n" + + color(AnsiFore.GREEN, " ✨ Up to 40% smaller binaries\n") + + color(AnsiFore.GREEN, " 🚀 Better performance and optimization\n") + + color(AnsiFore.GREEN, " 📦 Custom-built firmware for your exact needs\n") + + color( + AnsiFore.GREEN, + " 🔧 Active development and testing by ESPHome developers\n", + ) + + "\n" + + "Trade-offs:\n" + + color(AnsiFore.YELLOW, " ⏱️ Compile times are ~25% longer\n") + + color(AnsiFore.YELLOW, " 🔄 Some components need migration\n") + + "\n" + + "What should I do?\n" + + color(AnsiFore.CYAN, " Option 1") + + ": Migrate to ESP-IDF (recommended)\n" + + " Add this to your YAML under 'esp32:':\n" + + color(AnsiFore.WHITE, " framework:\n") + + color(AnsiFore.WHITE, " type: esp-idf\n") + + "\n" + + color(AnsiFore.CYAN, " Option 2") + + ": Keep using Arduino (still supported)\n" + + " Add this to your YAML under 'esp32:':\n" + + color(AnsiFore.WHITE, " framework:\n") + + color(AnsiFore.WHITE, " type: arduino\n") + + "\n" + + "Need help? Check out the migration guide:\n" + + color( + AnsiFore.BLUE, + "https://esphome.io/guides/esp32_arduino_to_idf.html", + ) + ) + _LOGGER.warning(message) + + def _set_default_framework(config): if CONF_FRAMEWORK not in config: config = config.copy() @@ -688,6 +746,10 @@ def _set_default_framework(config): if variant in ARDUINO_ALLOWED_VARIANTS: config[CONF_FRAMEWORK] = ARDUINO_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO + # Show the migration message + _show_framework_migration_message( + config.get(CONF_NAME, "This device"), variant + ) else: config[CONF_FRAMEWORK] = ESP_IDF_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF diff --git a/esphome/util.py b/esphome/util.py index 3b346371bc..9aa0f6b9d8 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -345,5 +345,11 @@ def get_esp32_arduino_flash_error_help() -> str | None: + "2. Clean build files and compile again\n" + "\n" + "Note: ESP-IDF uses less flash space and provides better performance.\n" - + "Some Arduino-specific libraries may need alternatives.\n\n" + + "Some Arduino-specific libraries may need alternatives.\n" + + "\n" + + "For detailed migration instructions, see:\n" + + color( + AnsiFore.BLUE, + "https://esphome.io/guides/esp32_arduino_to_idf.html\n\n", + ) ) From c0c0a423626b66abc5e35dbe6c02e116c566328d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:37:47 -1000 Subject: [PATCH 38/60] [api] Use static allocation for areas and devices in DeviceInfoResponse (#10038) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/api/api.proto | 4 ++-- esphome/components/api/api_connection.cpp | 12 ++++++++---- esphome/components/api/api_pb2.cpp | 12 ++++++++---- esphome/components/api/api_pb2.h | 6 +++--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e0b2c19a21..67e91cc8e3 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -250,8 +250,8 @@ message DeviceInfoResponse { // Supports receiving and saving api encryption key bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"]; - repeated DeviceInfo devices = 20 [(field_ifdef) = "USE_DEVICES"]; - repeated AreaInfo areas = 21 [(field_ifdef) = "USE_AREAS"]; + repeated DeviceInfo devices = 20 [(field_ifdef) = "USE_DEVICES", (fixed_array_size_define) = "ESPHOME_DEVICE_COUNT"]; + repeated AreaInfo areas = 21 [(field_ifdef) = "USE_AREAS", (fixed_array_size_define) = "ESPHOME_AREA_COUNT"]; // Top-level area info to phase out suggested_area AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5fff270c99..cdeabb5cac 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1462,18 +1462,22 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.api_encryption_supported = true; #endif #ifdef USE_DEVICES + size_t device_index = 0; for (auto const &device : App.get_devices()) { - resp.devices.emplace_back(); - auto &device_info = resp.devices.back(); + if (device_index >= ESPHOME_DEVICE_COUNT) + break; + auto &device_info = resp.devices[device_index++]; device_info.device_id = device->get_device_id(); device_info.set_name(StringRef(device->get_name())); device_info.area_id = device->get_area_id(); } #endif #ifdef USE_AREAS + size_t area_index = 0; for (auto const &area : App.get_areas()) { - resp.areas.emplace_back(); - auto &area_info = resp.areas.back(); + if (area_index >= ESPHOME_AREA_COUNT) + break; + auto &area_info = resp.areas[area_index++]; area_info.area_id = area->get_area_id(); area_info.set_name(StringRef(area->get_name())); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8c14153155..5dddc79b49 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -115,12 +115,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(19, this->api_encryption_supported); #endif #ifdef USE_DEVICES - for (auto &it : this->devices) { + for (const auto &it : this->devices) { buffer.encode_message(20, it, true); } #endif #ifdef USE_AREAS - for (auto &it : this->areas) { + for (const auto &it : this->areas) { buffer.encode_message(21, it, true); } #endif @@ -167,10 +167,14 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - size.add_repeated_message(2, this->devices); + for (const auto &it : this->devices) { + size.add_message_object_force(2, it); + } #endif #ifdef USE_AREAS - size.add_repeated_message(2, this->areas); + for (const auto &it : this->areas) { + size.add_message_object_force(2, it); + } #endif #ifdef USE_AREAS size.add_message_object(2, this->area); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 0bc75ef00b..d43d3c61b7 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -490,7 +490,7 @@ class DeviceInfo : public ProtoMessage { class DeviceInfoResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 211; + static constexpr uint8_t ESTIMATED_SIZE = 247; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -543,10 +543,10 @@ class DeviceInfoResponse : public ProtoMessage { bool api_encryption_supported{false}; #endif #ifdef USE_DEVICES - std::vector devices{}; + std::array devices{}; #endif #ifdef USE_AREAS - std::vector areas{}; + std::array areas{}; #endif #ifdef USE_AREAS AreaInfo area{}; From 4d683d5a69d0f3e54e0a5a827c9a6b2e02cc4cb1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:45:35 +1200 Subject: [PATCH 39/60] [AI] Add note about the defines.h file needing to include all new defines added (#10054) --- .ai/instructions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.ai/instructions.md b/.ai/instructions.md index 6504c7370d..6c002f9617 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -168,6 +168,8 @@ This document provides essential context for AI models interacting with this pro * `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers. * `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting. * **CI/CD Pipeline:** Defined in `.github/workflows`. +* **Static Analysis & Development:** + * `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms. ## 6. Development & Testing Workflow From a5f1661643aa04f534d90690b76cd08313e9e5e8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:43:04 +1200 Subject: [PATCH 40/60] [nfc] Rename ``binary_sensor`` source files (#10053) --- .../binary_sensor/{binary_sensor.cpp => nfc_binary_sensor.cpp} | 2 +- .../nfc/binary_sensor/{binary_sensor.h => nfc_binary_sensor.h} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename esphome/components/nfc/binary_sensor/{binary_sensor.cpp => nfc_binary_sensor.cpp} (99%) rename esphome/components/nfc/binary_sensor/{binary_sensor.h => nfc_binary_sensor.h} (100%) diff --git a/esphome/components/nfc/binary_sensor/binary_sensor.cpp b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp similarity index 99% rename from esphome/components/nfc/binary_sensor/binary_sensor.cpp rename to esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp index 8f1f6acd51..bc19fa7213 100644 --- a/esphome/components/nfc/binary_sensor/binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -1,4 +1,4 @@ -#include "binary_sensor.h" +#include "nfc_binary_sensor.h" #include "../nfc_helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/nfc/binary_sensor/binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h similarity index 100% rename from esphome/components/nfc/binary_sensor/binary_sensor.h rename to esphome/components/nfc/binary_sensor/nfc_binary_sensor.h From 3007ca4d57de1dce20e05c471a336bffea2f8bb4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:55:46 +1200 Subject: [PATCH 41/60] [core] Move docs url generator to helpers.py (#10056) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/config_validation.py | 12 ++---------- esphome/helpers.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 84ffd9941e..9aaeb9f9e8 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -87,7 +87,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import add_class_to_obj, list_starts_with +from esphome.helpers import add_class_to_obj, docs_url, list_starts_with from esphome.schema_extractors import ( SCHEMA_EXTRACT, schema_extractor, @@ -666,14 +666,6 @@ def only_with_framework( if suggestions is None: suggestions = {} - version = Version.parse(ESPHOME_VERSION) - if version.is_beta: - docs_format = "https://beta.esphome.io/components/{path}" - elif version.is_dev: - docs_format = "https://next.esphome.io/components/{path}" - else: - docs_format = "https://esphome.io/components/{path}" - def validator_(obj): if CORE.target_framework not in frameworks: err_str = f"This feature is only available with framework(s) {', '.join([framework.value for framework in frameworks])}" @@ -681,7 +673,7 @@ def only_with_framework( (component, docs_path) = suggestion err_str += f"\nPlease use '{component}'" if docs_path: - err_str += f": {docs_format.format(path=docs_path)}" + err_str += f": {docs_url(path=f'components/{docs_path}')}" raise Invalid(err_str) return obj diff --git a/esphome/helpers.py b/esphome/helpers.py index d1f3080e34..f722dc3f7c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -9,6 +9,8 @@ import re import tempfile from urllib.parse import urlparse +from esphome.const import __version__ as ESPHOME_VERSION + _LOGGER = logging.getLogger(__name__) IS_MACOS = platform.system() == "Darwin" @@ -503,3 +505,20 @@ _DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9-_]") def sanitize(value): """Same behaviour as `helpers.cpp` method `str_sanitize`.""" return _DISALLOWED_CHARS.sub("_", value) + + +def docs_url(path: str) -> str: + """Return the URL to the documentation for a given path.""" + # Local import to avoid circular import + from esphome.config_validation import Version + + version = Version.parse(ESPHOME_VERSION) + if version.is_beta: + docs_format = "https://beta.esphome.io/{path}" + elif version.is_dev: + docs_format = "https://next.esphome.io/{path}" + else: + docs_format = "https://esphome.io/{path}" + + path = path.removeprefix("/") + return docs_format.format(path=path) From bb3ebaf95572ff48f5633a4bd8777de106e856ce Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:55:54 +1000 Subject: [PATCH 42/60] [font] Catch file load exception (#10058) Co-authored-by: clydeps --- esphome/components/font/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7d9a35647e..4ecc76c561 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -15,6 +15,7 @@ from freetype import ( FT_LOAD_RENDER, FT_LOAD_TARGET_MONO, Face, + FT_Exception, ft_pixel_mode_mono, ) import requests @@ -94,7 +95,14 @@ class FontCache(MutableMapping): return self.store[self._keytransform(item)] def __setitem__(self, key, value): - self.store[self._keytransform(key)] = Face(str(value)) + transformed = self._keytransform(key) + try: + self.store[transformed] = Face(str(value)) + except FT_Exception as exc: + file = transformed.split(":", 1) + raise cv.Invalid( + f"{file[0].capitalize()} {file[1]} is not a valid font file" + ) from exc FONT_CACHE = FontCache() From 7c297366c72709000a573ba76c6fa019f0f86166 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 18:57:59 -1000 Subject: [PATCH 43/60] [esp32_ble_tracker] Remove unnecessary STOPPED scanner state to reduce latency (#10055) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 61 ++++++++----------- .../esp32_ble_tracker/esp32_ble_tracker.h | 16 +++-- 2 files changed, 33 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e0029ad15b..254eddd1d9 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -185,9 +185,6 @@ void ESP32BLETracker::loop() { ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); } } - if (this->scanner_state_ == ScannerState::STOPPED) { - this->end_of_scan_(); // Change state to IDLE - } if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->stop_scan_(); @@ -278,8 +275,6 @@ void ESP32BLETracker::stop_scan_() { ESP_LOGE(TAG, "Scan is starting while trying to stop."); } else if (this->scanner_state_ == ScannerState::STOPPING) { ESP_LOGE(TAG, "Scan is already stopping while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan is already stopped while trying to stop."); } return; } @@ -306,8 +301,6 @@ void ESP32BLETracker::start_scan_(bool first) { ESP_LOGE(TAG, "Cannot start scan while already stopping."); } else if (this->scanner_state_ == ScannerState::FAILED) { ESP_LOGE(TAG, "Cannot start scan while already failed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Cannot start scan while already stopped."); } return; } @@ -342,21 +335,6 @@ void ESP32BLETracker::start_scan_(bool first) { } } -void ESP32BLETracker::end_of_scan_() { - // The lock must be held when calling this function. - if (this->scanner_state_ != ScannerState::STOPPED) { - ESP_LOGE(TAG, "end_of_scan_ called while scanner is not stopped."); - return; - } - ESP_LOGD(TAG, "End of scan, set scanner state to IDLE."); - this->already_discovered_.clear(); - this->cancel_timeout("scan"); - - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->set_scanner_state_(ScannerState::IDLE); -} - void ESP32BLETracker::register_client(ESPBTClient *client) { client->app_id = ++this->app_id_; this->clients_.push_back(client); @@ -389,6 +367,8 @@ void ESP32BLETracker::recalculate_advertisement_parser_types() { } void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + // Note: This handler is called from the main loop context, not directly from the BT task. + // The esp32_ble component queues events via enqueue_ble_event() and processes them in loop(). switch (event) { case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: this->gap_scan_set_param_complete_(param->scan_param_cmpl); @@ -409,11 +389,13 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga } void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { + // Note: This handler is called from the main loop context via esp32_ble's event queue. + // However, we still use a lock-free ring buffer to batch results efficiently. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Lock-free SPSC ring buffer write (Producer side) - // This runs in the ESP-IDF Bluetooth stack callback thread + // Ring buffer write (Producer side) + // Even though we're in the main loop, the ring buffer design allows efficient batching // IMPORTANT: Only this thread writes to ring_write_index_ // Load our own index with relaxed ordering (we're the only writer) @@ -445,15 +427,15 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { ESP_LOGE(TAG, "Scan was in failed state when scan completed."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when scan completed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when scan completed."); } } - this->set_scanner_state_(ScannerState::STOPPED); + // Scan completed naturally, perform cleanup and transition to IDLE + this->cleanup_scan_state_(false); } } void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t::ble_scan_param_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task ESP_LOGV(TAG, "gap_scan_set_param_complete - status %d", param.status); if (param.status == ESP_BT_STATUS_DONE) { this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; @@ -463,6 +445,7 @@ void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t: } void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble_scan_start_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task ESP_LOGV(TAG, "gap_scan_start_complete - status %d", param.status); this->scan_start_failed_ = param.status; if (this->scanner_state_ != ScannerState::STARTING) { @@ -474,8 +457,6 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble ESP_LOGE(TAG, "Scan was in failed state when start complete."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when start complete."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when start complete."); } } if (param.status == ESP_BT_STATUS_SUCCESS) { @@ -490,6 +471,8 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble } void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task + // This allows us to safely transition to IDLE state and perform cleanup without race conditions ESP_LOGV(TAG, "gap_scan_stop_complete - status %d", param.status); if (this->scanner_state_ != ScannerState::STOPPING) { if (this->scanner_state_ == ScannerState::RUNNING) { @@ -500,11 +483,11 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ ESP_LOGE(TAG, "Scan was in failed state when stop complete."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when stop complete."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when stop complete."); } } - this->set_scanner_state_(ScannerState::STOPPED); + + // Perform cleanup and transition to IDLE + this->cleanup_scan_state_(true); } void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -794,9 +777,6 @@ void ESP32BLETracker::dump_config() { case ScannerState::STOPPING: ESP_LOGCONFIG(TAG, " Scanner State: STOPPING"); break; - case ScannerState::STOPPED: - ESP_LOGCONFIG(TAG, " Scanner State: STOPPED"); - break; case ScannerState::FAILED: ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); break; @@ -881,6 +861,17 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { } #endif // USE_ESP32_BLE_DEVICE +void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { + ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); + this->already_discovered_.clear(); + this->cancel_timeout("scan"); + + for (auto *listener : this->listeners_) + listener->on_scan_end(); + + this->set_scanner_state_(ScannerState::IDLE); +} + } // namespace esphome::esp32_ble_tracker #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index e1119c0e18..c274e64b12 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -158,18 +158,16 @@ enum class ClientState : uint8_t { }; enum class ScannerState { - // Scanner is idle, init state, set from the main loop when processing STOPPED + // Scanner is idle, init state IDLE, - // Scanner is starting, set from the main loop only + // Scanner is starting STARTING, - // Scanner is running, set from the ESP callback only + // Scanner is running RUNNING, - // Scanner failed to start, set from the ESP callback only + // Scanner failed to start FAILED, - // Scanner is stopping, set from the main loop only + // Scanner is stopping STOPPING, - // Scanner is stopped, set from the ESP callback only - STOPPED, }; enum class ConnectionType : uint8_t { @@ -262,8 +260,6 @@ class ESP32BLETracker : public Component, void stop_scan_(); /// Start a single scan by setting up the parameters and doing some esp-idf calls. void start_scan_(bool first); - /// Called when a scan ends - void end_of_scan_(); /// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received. void gap_scan_result_(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m); /// Called when a `ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT` event is received. @@ -274,6 +270,8 @@ class ESP32BLETracker : public Component, void gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m); /// Called to set the scanner state. Will also call callbacks to let listeners know when state is changed. void set_scanner_state_(ScannerState state); + /// Common cleanup logic when transitioning scanner to IDLE state + void cleanup_scan_state_(bool is_stop_complete); uint8_t app_id_{0}; From 989058e6a94b97dd76472ac40b2d6f7cccd4f9b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:12:06 -1000 Subject: [PATCH 44/60] [esp32_ble_client] Use FAST connection parameters for all v3 connections (#10052) --- .../esp32_ble_client/ble_client_base.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 94f2a6073c..031cb41e6d 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -16,8 +16,8 @@ static const char *const TAG = "esp32_ble_client"; // Intermediate connection parameters for standard operation // ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, // causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x08; // 8 * 1.25ms = 10ms -static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms // The timeout value was increased from 6s to 8s to address stability issues observed // in certain BLE devices when operating through WiFi-based BLE proxies. The longer // timeout reduces the likelihood of disconnections during periods of high latency. @@ -157,12 +157,13 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::CONNECTING); // Always set connection parameters to ensure stable operation - // Use FAST for V3_WITHOUT_CACHE (devices that need lowest latency) - // Use MEDIUM for all other connections (balanced performance) + // Use FAST for all V3 connections (better latency and reliability) + // Use MEDIUM for V1/legacy connections (balanced performance) uint16_t min_interval, max_interval, timeout; const char *param_type; - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { min_interval = FAST_MIN_CONN_INTERVAL; max_interval = FAST_MAX_CONN_INTERVAL; timeout = FAST_CONN_TIMEOUT; @@ -411,9 +412,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For non-cached connections, restore to medium connection parameters after service discovery + // For V3 connections, restore to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; From 6be22a5ea9c9b03ee125e4d77aceb90a77154a1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:15:28 -1000 Subject: [PATCH 45/60] [esp32_ble_client] Connect immediately on READY_TO_CONNECT to reduce latency (#10051) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 031cb41e6d..2c13995f76 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -45,8 +45,10 @@ void BLEClientBase::set_state(espbt::ClientState st) { ESPBTClient::set_state(st); if (st == espbt::ClientState::READY_TO_CONNECT) { - // Enable loop when we need to connect + // Enable loop for state processing this->enable_loop(); + // Connect immediately instead of waiting for next loop + this->connect(); } } @@ -63,11 +65,6 @@ void BLEClientBase::loop() { } this->set_state(espbt::ClientState::IDLE); } - // READY_TO_CONNECT means we have discovered the device - // and the scanner has been stopped by the tracker. - else if (this->state_ == espbt::ClientState::READY_TO_CONNECT) { - this->connect(); - } // If its idle, we can disable the loop as set_state // will enable it again when we need to connect. else if (this->state_ == espbt::ClientState::IDLE) { From 36c44303172b60acdd2339b224ebfda6957f4b0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:41:41 -1000 Subject: [PATCH 46/60] [esp32_ble] Fix BLE connection slot waste by aligning ESP-IDF timeout with client timeout (#10013) --- esphome/components/esp32_ble/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 93bb643596..1c7c075cfa 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME -from esphome.core import CORE +from esphome.core import CORE, TimePeriod from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv @@ -117,6 +117,7 @@ CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" +CONF_CONNECTION_TIMEOUT = "connection_timeout" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] @@ -167,6 +168,11 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), + cv.SplitDefault(CONF_CONNECTION_TIMEOUT, esp32_idf="20s"): cv.All( + cv.only_with_esp_idf, + cv.positive_time_period_seconds, + cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -255,6 +261,17 @@ async def to_code(config): if logger not in _required_loggers: add_idf_sdkconfig_option(f"{logger.value}_NONE", True) + # Set BLE connection establishment timeout to match aioesphomeapi/bleak-retry-connector + # Default is 20 seconds instead of ESP-IDF's 30 seconds. Because there is no way to + # cancel a BLE connection in progress, when aioesphomeapi times out at 20 seconds, + # the connection slot remains occupied for the remaining time, preventing new connection + # attempts and wasting valuable connection slots. + if CONF_CONNECTION_TIMEOUT in config: + timeout_seconds = int(config[CONF_CONNECTION_TIMEOUT].total_seconds) + add_idf_sdkconfig_option( + "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds + ) + cg.add_define("USE_ESP32_BLE") From fbbb791b0dda790e6e6443766f0a04991bf2d73d Mon Sep 17 00:00:00 2001 From: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:37:43 +0200 Subject: [PATCH 47/60] [gt911] Use timeout instead of delay, shortened log msg (#10024) Co-authored-by: Keith Burzinski --- .../gt911/touchscreen/gt911_touchscreen.cpp | 30 +++++++++++++------ .../gt911/touchscreen/gt911_touchscreen.h | 28 +++++++++++++++-- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 0319b083ef..07218843dd 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -20,12 +20,11 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - this->status_set_warning("Communication failure"); \ + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); \ return; \ } void GT911Touchscreen::setup() { - i2c::ErrorCode err; if (this->reset_pin_ != nullptr) { this->reset_pin_->setup(); this->reset_pin_->digital_write(false); @@ -35,9 +34,14 @@ void GT911Touchscreen::setup() { this->interrupt_pin_->digital_write(false); } delay(2); - this->reset_pin_->digital_write(true); - delay(50); // NOLINT + this->reset_pin_->digital_write(true); // wait 50ms after reset + this->set_timeout(50, [this] { this->setup_internal_(); }); + return; } + this->setup_internal_(); +} + +void GT911Touchscreen::setup_internal_() { if (this->interrupt_pin_ != nullptr) { // set pre-configured input mode this->interrupt_pin_->setup(); @@ -45,7 +49,7 @@ void GT911Touchscreen::setup() { // check the configuration of the int line. uint8_t data[4]; - err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); + i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); if (err != i2c::ERROR_OK && this->address_ == PRIMARY_ADDRESS) { this->address_ = SECONDARY_ADDRESS; err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); @@ -53,7 +57,7 @@ void GT911Touchscreen::setup() { if (err == i2c::ERROR_OK) { err = this->read(data, 1); if (err == i2c::ERROR_OK) { - ESP_LOGD(TAG, "Read from switches at address 0x%02X: 0x%02X", this->address_, data[0]); + ESP_LOGD(TAG, "Switches ADDR: 0x%02X DATA: 0x%02X", this->address_, data[0]); if (this->interrupt_pin_ != nullptr) { this->attach_interrupt_(this->interrupt_pin_, (data[0] & 1) ? gpio::INTERRUPT_FALLING_EDGE : gpio::INTERRUPT_RISING_EDGE); @@ -75,16 +79,24 @@ void GT911Touchscreen::setup() { } } if (err != i2c::ERROR_OK) { - this->mark_failed("Failed to read calibration"); + this->mark_failed("Calibration error"); return; } } + if (err != i2c::ERROR_OK) { - this->mark_failed("Failed to communicate"); + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; } + this->setup_done_ = true; } void GT911Touchscreen::update_touches() { + this->skip_update_ = true; // skip send touch events by default, set to false after successful error checks + if (!this->setup_done_) { + return; + } + i2c::ErrorCode err; uint8_t touch_state = 0; uint8_t data[MAX_TOUCHES + 1][8]; // 8 bytes each for each point, plus extra space for the key byte @@ -97,7 +109,6 @@ void GT911Touchscreen::update_touches() { uint8_t num_of_touches = touch_state & 0x07; if ((touch_state & 0x80) == 0 || num_of_touches > MAX_TOUCHES) { - this->skip_update_ = true; // skip send touch events, touchscreen is not ready yet. return; } @@ -107,6 +118,7 @@ void GT911Touchscreen::update_touches() { err = this->read(data[0], sizeof(data[0]) * num_of_touches + 1); ERROR_CHECK(err); + this->skip_update_ = false; // All error checks passed, send touch events for (uint8_t i = 0; i != num_of_touches; i++) { uint16_t id = data[i][0]; uint16_t x = encode_uint16(data[i][2], data[i][1]); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 17636a2ada..85025b5522 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -15,8 +15,20 @@ class GT911ButtonListener { class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { public: + /// @brief Initialize the GT911 touchscreen. + /// + /// If @ref reset_pin_ is set, the touchscreen will be hardware reset, + /// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout() + /// to allow the device to stabilize after reset. + /// + /// If @ref interrupt_pin_ is set, it will be temporarily configured during reset + /// to control I2C address selection. + /// + /// After the timeout, or immediately if no reset is performed, @ref setup_internal_() + /// is called to complete the initialization. void setup() override; void dump_config() override; + bool can_proceed() override { return this->setup_done_; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } @@ -25,8 +37,20 @@ class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice protected: void update_touches() override; - InternalGPIOPin *interrupt_pin_{}; - GPIOPin *reset_pin_{}; + /// @brief Perform the internal setup routine for the GT911 touchscreen. + /// + /// This function checks the I2C address, configures the interrupt pin (if available), + /// reads the touchscreen mode from the controller, and attempts to read calibration + /// data (maximum X and Y values) if not already set. + /// + /// On success, sets @ref setup_done_ to true. + /// On failure, calls @ref mark_failed() with an appropriate error message. + void setup_internal_(); + /// @brief True if the touchscreen setup has completed successfully. + bool setup_done_{false}; + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; std::vector button_listeners_; uint8_t button_state_{0xFF}; // last button state. Initial FF guarantees first update. }; From d59476d0e102ba833eaa3a0d242d931a44fa245a Mon Sep 17 00:00:00 2001 From: Chris Beswick Date: Mon, 4 Aug 2025 15:43:44 +0100 Subject: [PATCH 48/60] [i2s_audio] Use high-pass filter for dc offset correction (#10005) --- .../microphone/i2s_audio_microphone.cpp | 64 +++++++++++++------ .../microphone/i2s_audio_microphone.h | 3 +- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 5ca33b3493..cdebc214e2 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -24,9 +24,6 @@ static const uint32_t READ_DURATION_MS = 16; static const size_t TASK_STACK_SIZE = 4096; static const ssize_t TASK_PRIORITY = 23; -// Use an exponential moving average to correct a DC offset with weight factor 1/1000 -static const int32_t DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR = 1000; - static const char *const TAG = "i2s_audio.microphone"; enum MicrophoneEventGroupBits : uint32_t { @@ -381,26 +378,57 @@ void I2SAudioMicrophone::mic_task(void *params) { } void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { + /** + * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: + * + * y(n) = x(n) - x(n-1) + R * y(n-1) + * R = 1 - (pi * 2 * frequency / samplerate) + * + * From https://en.wikipedia.org/wiki/Hearing_range: + * The human range is commonly given as 20Hz up. + * + * From https://en.wikipedia.org/wiki/High-resolution_audio: + * A reasonable upper bound for sample rate seems to be 96kHz. + * + * Calculate R value for 20Hz on a 96kHz sample rate: + * R = 1 - (pi * 2 * 20 / 96000) + * R = 0.9986910031 + * + * Transform floating point to bit-shifting approximation: + * output = input - prev_input + R * prev_output + * output = input - prev_input + (prev_output - (prev_output >> S)) + * + * Approximate bit-shift value S from R: + * R = 1 - (1 >> S) + * R = 1 - (1 / 2^S) + * R = 1 - 2^-S + * 0.9986910031 = 1 - 2^-S + * S = 9.57732 ~= 10 + * + * Actual R from S: + * R = 1 - 2^-10 = 0.9990234375 + * + * Confirm this has effect outside human hearing on 96000kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 96000) + * f = 14.9208Hz + * + * Confirm this has effect outside human hearing on PDM 16kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 16000) + * f = 2.4868Hz + * + */ + const uint8_t dc_filter_shift = 10; const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); - - if (total_samples == 0) { - return; - } - - int64_t offset_accumulator = 0; for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { const uint32_t byte_index = sample_index * bytes_per_sample; - int32_t sample = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); - offset_accumulator += sample; - sample -= this->dc_offset_; - audio::pack_q31_as_audio_sample(sample, &data[byte_index], bytes_per_sample); + int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); + int32_t output = input - this->dc_offset_prev_input_ + + (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); + this->dc_offset_prev_input_ = input; + this->dc_offset_prev_output_ = output; + audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); } - - const int32_t new_offset = offset_accumulator / total_samples; - this->dc_offset_ = new_offset / DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR + - (DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR - 1) * this->dc_offset_ / - DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR; } size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 633bd0e7dd..de272ba23d 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -82,7 +82,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub bool correct_dc_offset_; bool locked_driver_{false}; - int32_t dc_offset_{0}; + int32_t dc_offset_prev_input_{0}; + int32_t dc_offset_prev_output_{0}; }; } // namespace i2s_audio From 701e6099aa42cc84be629cc0b6258992fe3aaef2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:56:34 -0400 Subject: [PATCH 49/60] [espnow, web_server_idf] Fix IDF 5.5 compile issues (#10068) --- esphome/components/espnow/espnow_packet.h | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index d39f7d2c24..b6192a0d41 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -49,7 +49,7 @@ class ESPNowPacket { #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) // Constructor for sent data ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data(info->src_addr, status); + this->init_sent_data_(info->src_addr, status); } #else // Constructor for sent data diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 40fb015b99..3226444b1b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -116,7 +116,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } // Handle regular form data - if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { + if (r->content_len > CONFIG_HTTPD_MAX_REQ_HDR_LEN) { ESP_LOGW(TAG, "Request size is to big: %zu", r->content_len); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; From 83d9c02a1bc80e16176126030ebefbaa03d626b9 Mon Sep 17 00:00:00 2001 From: mschnaubelt <33265999+mschnaubelt@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:41:55 +0200 Subject: [PATCH 50/60] Add CO5300 display support (#9739) --- esphome/components/mipi/__init__.py | 2 ++ esphome/components/mipi_spi/models/amoled.py | 18 ++++++++++++++++++ .../components/mipi_spi/models/waveshare.py | 12 ++++++++++++ 3 files changed, 32 insertions(+) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index b9299bb8d7..f610f160b0 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -77,6 +77,7 @@ BRIGHTNESS = 0x51 WRDISBV = 0x51 RDDISBV = 0x52 WRCTRLD = 0x53 +WCE = 0x58 SWIRE1 = 0x5A SWIRE2 = 0x5B IFMODE = 0xB0 @@ -91,6 +92,7 @@ PWCTR2 = 0xC1 PWCTR3 = 0xC2 PWCTR4 = 0xC3 PWCTR5 = 0xC4 +SPIMODESEL = 0xC4 VMCTR1 = 0xC5 IFCTR = 0xC6 VMCTR2 = 0xC7 diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 6fe882b584..bc95fc7f71 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -5,10 +5,13 @@ from esphome.components.mipi import ( PAGESEL, PIXFMT, SLPOUT, + SPIMODESEL, SWIRE1, SWIRE2, TEON, + WCE, WRAM, + WRCTRLD, DriverChip, delay, ) @@ -87,4 +90,19 @@ T4_S3_AMOLED = RM690B0.extend( bus_mode=TYPE_QUAD, ) +CO5300 = DriverChip( + "CO5300", + brightness=0xD0, + color_order=MODE_RGB, + bus_mode=TYPE_QUAD, + initsequence=( + (SLPOUT,), # Requires early SLPOUT + (PAGESEL, 0x00), + (SPIMODESEL, 0x80), + (WRCTRLD, 0x20), + (WCE, 0x00), + ), +) + + models = {} diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 002f81f3a6..7a55027e58 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -1,6 +1,7 @@ from esphome.components.mipi import DriverChip import esphome.config_validation as cv +from .amoled import CO5300 from .ili import ILI9488_A DriverChip( @@ -140,3 +141,14 @@ ILI9488_A.extend( data_rate="20MHz", invert_colors=True, ) + +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.75", + width=466, + height=466, + pixel_mode="16bit", + offset_height=0, + offset_width=6, + cs_pin=12, + reset_pin=39, +) From 50f15735dca74989c6cab7860d7f20b725ca286f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 14:57:31 -1000 Subject: [PATCH 51/60] [api] Add helpful compile-time errors for Custom API Device methods (#10076) --- esphome/components/api/custom_api_device.h | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index a39947e725..44f9eee571 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -56,6 +56,14 @@ class CustomAPIDevice { auto *service = new CustomAPIDeviceService(name, arg_names, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); } +#else + template + void register_service(void (T::*callback)(Ts...), const std::string &name, + const std::array &arg_names) { + static_assert( + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + } #endif /** Register a custom native API service that will show up in Home Assistant. @@ -81,6 +89,12 @@ class CustomAPIDevice { auto *service = new CustomAPIDeviceService(name, {}, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); } +#else + template void register_service(void (T::*callback)(), const std::string &name) { + static_assert( + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + } #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -135,6 +149,22 @@ class CustomAPIDevice { auto f = std::bind(callback, (T *) this, entity_id, std::placeholders::_1); global_api_server->subscribe_home_assistant_state(entity_id, optional(attribute), f); } +#else + template + void subscribe_homeassistant_state(void (T::*callback)(std::string), const std::string &entity_id, + const std::string &attribute = "") { + static_assert(sizeof(T) == 0, + "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); + } + + template + void subscribe_homeassistant_state(void (T::*callback)(std::string, std::string), const std::string &entity_id, + const std::string &attribute = "") { + static_assert(sizeof(T) == 0, + "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); + } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES @@ -222,6 +252,28 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } +#else + template void call_homeassistant_service(const std::string &service_name) { + static_assert(sizeof(T) == 0, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template + void call_homeassistant_service(const std::string &service_name, const std::map &data) { + static_assert(sizeof(T) == 0, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template void fire_homeassistant_event(const std::string &event_name) { + static_assert(sizeof(T) == 0, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template + void fire_homeassistant_event(const std::string &service_name, const std::map &data) { + static_assert(sizeof(T) == 0, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } #endif }; From 469246b8d8de21fa6ad1d74e34cd2f16cac4a878 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 14:58:41 -1000 Subject: [PATCH 52/60] [bluetooth_proxy] Warn about BLE connection timeout mismatch on Arduino framework (#10063) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/bluetooth_proxy/__init__.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ec1df6a06c..4087255410 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -1,14 +1,20 @@ +import logging + import esphome.codegen as cg from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID +from esphome.core import CORE +from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] CODEOWNERS = ["@jesserockz"] +_LOGGER = logging.getLogger(__name__) + CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" @@ -41,6 +47,27 @@ def validate_connections(config): esp32_ble_tracker.consume_connection_slots(connection_slots, "bluetooth_proxy")( config ) + + # Warn about connection slot waste when using Arduino framework + if CORE.using_arduino and connection_slots: + _LOGGER.warning( + "Bluetooth Proxy with active connections on Arduino framework has suboptimal performance.\n" + "If BLE connections fail, they can waste connection slots for 10 seconds because\n" + "Arduino doesn't allow configuring the BLE connection timeout (fixed at 30s).\n" + "ESP-IDF framework allows setting it to 20s to match client timeouts.\n" + "\n" + "To switch to ESP-IDF, add this to your YAML:\n" + " esp32:\n" + " framework:\n" + " type: esp-idf\n" + "\n" + "For detailed migration instructions, see:\n" + "%s", + color( + AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" + ), + ) + return { **config, CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], From 27ba90ea95320cc8aab5d9da3ac81abc72aa2061 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 14:59:23 -1000 Subject: [PATCH 53/60] [esp32_ble_client] Start MTU negotiation earlier following ESP-IDF examples (#10062) --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2c13995f76..9b07033cfc 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -283,7 +283,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_event_("ESP_GATTC_OPEN_EVT"); - this->conn_id_ = param->open.conn_id; + // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does @@ -317,11 +317,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->conn_id_ = UNSET_CONN_ID; break; } - auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->open.conn_id); - if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, - this->address_str_.c_str(), ret); - } + // MTU negotiation already started in ESP_GATTC_CONNECT_EVT this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { @@ -338,6 +334,16 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->connect.remote_bda)) return false; this->log_event_("ESP_GATTC_CONNECT_EVT"); + this->conn_id_ = param->connect.conn_id; + // Start MTU negotiation immediately as recommended by ESP-IDF examples + // (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in + // ESP_GATTC_CONNECT_EVT instead of waiting for ESP_GATTC_OPEN_EVT. + // This saves ~3ms in the connection process. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, + this->address_str_.c_str(), ret); + } break; } case ESP_GATTC_DISCONNECT_EVT: { From fa8c5e880c9f60cfab7282a7faea6a4b1222119c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:10:02 -1000 Subject: [PATCH 54/60] [esp32_ble_tracker] Optimize connection by promoting client immediately after scan stop trigger (#10061) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 254eddd1d9..9e41fc80c5 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -238,19 +238,21 @@ void ESP32BLETracker::loop() { if (this->scanner_state_ == ScannerState::RUNNING) { ESP_LOGD(TAG, "Stopping scan to make connection"); this->stop_scan_(); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGD(TAG, "Promoting client to connect"); - // We only want to promote one client at a time. - // once the scanner is fully stopped. -#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); - if (!this->coex_prefer_ble_) { - this->coex_prefer_ble_ = true; - esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth - } -#endif - client->set_state(ClientState::READY_TO_CONNECT); + // Don't wait for scan stop complete - promote immediately. + // This is safe because ESP-IDF processes BLE commands sequentially through its internal mailbox queue. + // This guarantees that the stop scan command will be fully processed before any subsequent connect command, + // preventing race conditions or overlapping operations. } + + ESP_LOGD(TAG, "Promoting client to connect"); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); + if (!this->coex_prefer_ble_) { + this->coex_prefer_ble_ = true; + esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth + } +#endif + client->set_state(ClientState::READY_TO_CONNECT); break; } } From f7bf1ef52c2e77c60a41c3b56f0c731c1ad00fc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:10:32 -1000 Subject: [PATCH 55/60] [esp32_ble_tracker] Eliminate redundant ring buffer for lower latency (#10057) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_ble/ble.h | 15 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 170 ++++++++---------- .../esp32_ble_tracker/esp32_ble_tracker.h | 18 +- 3 files changed, 84 insertions(+), 119 deletions(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 543b2f26a3..3f40c557f1 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -23,21 +23,14 @@ namespace esphome::esp32_ble { -// Maximum number of BLE scan results to buffer -// Sized to handle bursts of advertisements while allowing for processing delays -// With 16 advertisements per batch and some safety margin: -// - Without PSRAM: 24 entries (1.5× batch size) -// - With PSRAM: 36 entries (2.25× batch size) -// The reduced structure size (~80 bytes vs ~400 bytes) allows for larger buffers +// Maximum size of the BLE event queue +// Increased to absorb the ring buffer capacity from esp32_ble_tracker #ifdef USE_PSRAM -static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 36; +static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 100; // 64 + 36 (ring buffer size with PSRAM) #else -static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 24; +static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 88; // 64 + 24 (ring buffer size without PSRAM) #endif -// Maximum size of the BLE event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_BLE_QUEUE_SIZE = 64; - uint64_t ble_addr_to_uint64(const esp_bd_addr_t address); // NOLINTNEXTLINE(modernize-use-using) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 9e41fc80c5..856ae82dca 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -49,13 +49,6 @@ void ESP32BLETracker::setup() { ESP_LOGE(TAG, "BLE Tracker was marked failed by ESP32BLE"); return; } - RAMAllocator allocator; - this->scan_ring_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); - - if (this->scan_ring_buffer_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate ring buffer for BLE Tracker!"); - this->mark_failed(); - } global_esp32_ble_tracker = this; @@ -117,74 +110,8 @@ void ESP32BLETracker::loop() { } bool promote_to_connecting = discovered && !searching && !connecting; - // Process scan results from lock-free SPSC ring buffer - // Consumer side: This runs in the main loop thread - if (this->scanner_state_ == ScannerState::RUNNING) { - // Load our own index with relaxed ordering (we're the only writer) - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_relaxed); - - // Load producer's index with acquire to see their latest writes - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); - - while (read_idx != write_idx) { - // Calculate how many contiguous results we can process in one batch - // If write > read: process all results from read to write - // If write <= read (wraparound): process from read to end of buffer first - size_t batch_size = (write_idx > read_idx) ? (write_idx - read_idx) : (SCAN_RESULT_BUFFER_SIZE - read_idx); - - // Process the batch for raw advertisements - if (this->raw_advertisements_) { - for (auto *listener : this->listeners_) { - listener->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - for (auto *client : this->clients_) { - client->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - } - - // Process individual results for parsed advertisements - if (this->parse_advertisements_) { -#ifdef USE_ESP32_BLE_DEVICE - for (size_t i = 0; i < batch_size; i++) { - BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx + i]; - ESPBTDevice device; - device.parse_scan_rst(scan_result); - - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; - } - - for (auto *client : this->clients_) { - if (client->parse_device(device)) { - found = true; - if (!connecting && client->state() == ClientState::DISCOVERED) { - promote_to_connecting = true; - } - } - } - - if (!found && !this->scan_continuous_) { - this->print_bt_device_info(device); - } - } -#endif // USE_ESP32_BLE_DEVICE - } - - // Update read index for entire batch - read_idx = (read_idx + batch_size) % SCAN_RESULT_BUFFER_SIZE; - - // Store with release to ensure reads complete before index update - this->ring_read_index_.store(read_idx, std::memory_order_release); - } - - // Log dropped results periodically - size_t dropped = this->scan_results_dropped_.exchange(0, std::memory_order_relaxed); - if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); - } - } + // All scan result processing is now done immediately in gap_scan_event_handler + // No ring buffer processing needed here if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->stop_scan_(); @@ -229,8 +156,10 @@ void ESP32BLETracker::loop() { } // If there is a discovered client and no connecting // clients and no clients using the scanner to search for - // devices, then stop scanning and promote the discovered - // client to ready to connect. + // devices, then promote the discovered client to ready to connect. + // Note: Scanning is already stopped by gap_scan_event_handler when + // a discovered client is found, so we only need to handle promotion + // when the scanner is IDLE. if (promote_to_connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { for (auto *client : this->clients_) { @@ -392,31 +321,18 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. - // However, we still use a lock-free ring buffer to batch results efficiently. + // We process advertisements immediately instead of buffering them. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Ring buffer write (Producer side) - // Even though we're in the main loop, the ring buffer design allows efficient batching - // IMPORTANT: Only this thread writes to ring_write_index_ + // Process the scan result immediately + bool found_discovered_client = this->process_scan_result_(scan_result); - // Load our own index with relaxed ordering (we're the only writer) - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_relaxed); - uint8_t next_write_idx = (write_idx + 1) % SCAN_RESULT_BUFFER_SIZE; - - // Load consumer's index with acquire to see their latest updates - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_acquire); - - // Check if buffer is full - if (next_write_idx != read_idx) { - // Write to ring buffer - this->scan_ring_buffer_[write_idx] = scan_result; - - // Store with release to ensure the write is visible before index update - this->ring_write_index_.store(next_write_idx, std::memory_order_release); - } else { - // Buffer full, track dropped results - this->scan_results_dropped_.fetch_add(1, std::memory_order_relaxed); + // If we found a discovered client that needs promotion, stop scanning + // This replaces the promote_to_connecting logic from loop() + if (found_discovered_client && this->scanner_state_ == ScannerState::RUNNING) { + ESP_LOGD(TAG, "Found discovered client, stopping scan for connection"); + this->stop_scan_(); } } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own @@ -861,8 +777,66 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); } + +bool ESP32BLETracker::has_connecting_clients_() const { + for (auto *client : this->clients_) { + auto state = client->state(); + if (state == ClientState::CONNECTING || state == ClientState::READY_TO_CONNECT) { + return true; + } + } + return false; +} #endif // USE_ESP32_BLE_DEVICE +bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { + bool found_discovered_client = false; + + // Process raw advertisements + if (this->raw_advertisements_) { + for (auto *listener : this->listeners_) { + listener->parse_devices(&scan_result, 1); + } + for (auto *client : this->clients_) { + client->parse_devices(&scan_result, 1); + } + } + + // Process parsed advertisements + if (this->parse_advertisements_) { +#ifdef USE_ESP32_BLE_DEVICE + ESPBTDevice device; + device.parse_scan_rst(scan_result); + + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) + found = true; + } + + for (auto *client : this->clients_) { + if (client->parse_device(device)) { + found = true; + // Check if this client is discovered and needs promotion + if (client->state() == ClientState::DISCOVERED) { + // Only check for connecting clients if we found a discovered client + // This matches the original logic: !connecting && client->state() == DISCOVERED + if (!this->has_connecting_clients_()) { + found_discovered_client = true; + } + } + } + } + + if (!found && !this->scan_continuous_) { + this->print_bt_device_info(device); + } +#endif // USE_ESP32_BLE_DEVICE + } + + return found_discovered_client; +} + void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); this->already_discovered_.clear(); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c274e64b12..1c28bc7a7d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -6,7 +6,6 @@ #include "esphome/core/helpers.h" #include -#include #include #include @@ -21,6 +20,7 @@ #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/esp32_ble/ble_scan_result.h" namespace esphome::esp32_ble_tracker { @@ -272,6 +272,13 @@ class ESP32BLETracker : public Component, void set_scanner_state_(ScannerState state); /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); + /// Process a single scan result immediately + /// Returns true if a discovered client needs promotion to READY_TO_CONNECT + bool process_scan_result_(const BLEScanResult &scan_result); +#ifdef USE_ESP32_BLE_DEVICE + /// Check if any clients are in connecting or ready to connect state + bool has_connecting_clients_() const; +#endif uint8_t app_id_{0}; @@ -295,15 +302,6 @@ class ESP32BLETracker : public Component, bool raw_advertisements_{false}; bool parse_advertisements_{false}; - // Lock-free Single-Producer Single-Consumer (SPSC) ring buffer for scan results - // Producer: ESP-IDF Bluetooth stack callback (gap_scan_event_handler) - // Consumer: ESPHome main loop (loop() method) - // This design ensures zero blocking in the BT callback and prevents scan result loss - BLEScanResult *scan_ring_buffer_; - std::atomic ring_write_index_{0}; // Written only by BT callback (producer) - std::atomic ring_read_index_{0}; // Written only by main loop (consumer) - std::atomic scan_results_dropped_{0}; // Tracks buffer overflow events - esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; int connecting_{0}; From 64c94c144003b1da017eeaec0047d551f2ec7671 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:11:32 -1000 Subject: [PATCH 56/60] [esp32_ble_client] Fix connection parameter timing by setting preferences before connection (#10059) --- .../esp32_ble_client/ble_client_base.cpp | 86 +++++++++++-------- .../esp32_ble_client/ble_client_base.h | 1 + 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 9b07033cfc..f47642944b 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -145,6 +145,36 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; + // Set preferred connection parameters before connecting + // Use FAST for all V3 connections (better latency and reliability) + // Use MEDIUM for V1/legacy connections (balanced performance) + uint16_t min_interval, max_interval, timeout; + const char *param_type; + + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + min_interval = FAST_MIN_CONN_INTERVAL; + max_interval = FAST_MAX_CONN_INTERVAL; + timeout = FAST_CONN_TIMEOUT; + param_type = "fast"; + } else { + min_interval = MEDIUM_MIN_CONN_INTERVAL; + max_interval = MEDIUM_MAX_CONN_INTERVAL; + timeout = MEDIUM_CONN_TIMEOUT; + param_type = "medium"; + } + + auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, + 0, // latency: 0 + timeout); + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); + } + + // Now open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), @@ -152,35 +182,6 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); - - // Always set connection parameters to ensure stable operation - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; - } - - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); - } } } @@ -255,6 +256,19 @@ void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); } +void BLEClientBase::restore_medium_conn_params_() { + // Restore to medium connection parameters after initial connection phase + // This balances performance with bandwidth usage for normal operation + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; + conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; + conn_params.latency = 0; + conn_params.timeout = MEDIUM_CONN_TIMEOUT; + ESP_LOGD(TAG, "[%d] [%s] Restoring medium conn params", this->connection_index_, this->address_str_.c_str()); + esp_ble_gap_update_conn_params(&conn_params); +} + bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, esp_ble_gattc_cb_param_t *param) { if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) @@ -322,6 +336,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); + + // Restore to medium connection parameters for cached connections too + this->restore_medium_conn_params_(); + // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; @@ -419,15 +437,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - esp_ble_conn_update_params_t conn_params = {{0}}; - memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restored medium conn params after service discovery", this->connection_index_, - this->address_str_.c_str()); - esp_ble_gap_update_conn_params(&conn_params); + this->restore_medium_conn_params_(); } this->state_ = espbt::ClientState::ESTABLISHED; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0a2fda4476..b30e9cd444 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -127,6 +127,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // 6 bytes used, 2 bytes padding void log_event_(const char *name); + void restore_medium_conn_params_(); }; } // namespace esp32_ble_client From 52634dac2a467c16a35e0540c76706996a1e1206 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:12:05 -1000 Subject: [PATCH 57/60] [tests] Add datetime entities to host_mode_many_entities integration test (#10032) --- .../fixtures/host_mode_many_entities.yaml | 17 ++++ .../test_host_mode_many_entities.py | 95 ++++++++++++++++--- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/tests/integration/fixtures/host_mode_many_entities.yaml b/tests/integration/fixtures/host_mode_many_entities.yaml index 5e085a15c9..612186507c 100644 --- a/tests/integration/fixtures/host_mode_many_entities.yaml +++ b/tests/integration/fixtures/host_mode_many_entities.yaml @@ -373,3 +373,20 @@ button: name: "Test Button" on_press: - logger.log: "Button pressed" + +# Date, Time, and DateTime entities +datetime: + - platform: template + type: date + name: "Test Date" + initial_value: "2023-05-13" + optimistic: true + - platform: template + type: time + name: "Test Time" + initial_value: "12:30:00" + optimistic: true + - platform: template + type: datetime + name: "Test DateTime" + optimistic: true diff --git a/tests/integration/test_host_mode_many_entities.py b/tests/integration/test_host_mode_many_entities.py index aaca4555f6..fbe3dc25c8 100644 --- a/tests/integration/test_host_mode_many_entities.py +++ b/tests/integration/test_host_mode_many_entities.py @@ -4,7 +4,17 @@ from __future__ import annotations import asyncio -from aioesphomeapi import ClimateInfo, EntityState, SensorState +from aioesphomeapi import ( + ClimateInfo, + DateInfo, + DateState, + DateTimeInfo, + DateTimeState, + EntityState, + SensorState, + TimeInfo, + TimeState, +) import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -22,34 +32,56 @@ async def test_host_mode_many_entities( async with run_compiled(yaml_config), api_client_connected() as client: # Subscribe to state changes states: dict[int, EntityState] = {} - sensor_count_future: asyncio.Future[int] = loop.create_future() + minimum_states_future: asyncio.Future[None] = loop.create_future() def on_state(state: EntityState) -> None: states[state.key] = state - # Count sensor states specifically + # Check if we have received minimum expected states sensor_states = [ s for s in states.values() if isinstance(s, SensorState) and isinstance(s.state, float) ] - # When we have received states from at least 50 sensors, resolve the future - if len(sensor_states) >= 50 and not sensor_count_future.done(): - sensor_count_future.set_result(len(sensor_states)) + date_states = [s for s in states.values() if isinstance(s, DateState)] + time_states = [s for s in states.values() if isinstance(s, TimeState)] + datetime_states = [ + s for s in states.values() if isinstance(s, DateTimeState) + ] + + # We expect at least 50 sensors and 1 of each datetime entity type + if ( + len(sensor_states) >= 50 + and len(date_states) >= 1 + and len(time_states) >= 1 + and len(datetime_states) >= 1 + and not minimum_states_future.done() + ): + minimum_states_future.set_result(None) client.subscribe_states(on_state) - # Wait for states from at least 50 sensors with timeout + # Wait for minimum states with timeout try: - sensor_count = await asyncio.wait_for(sensor_count_future, timeout=10.0) + await asyncio.wait_for(minimum_states_future, timeout=10.0) except TimeoutError: sensor_states = [ s for s in states.values() if isinstance(s, SensorState) and isinstance(s.state, float) ] + date_states = [s for s in states.values() if isinstance(s, DateState)] + time_states = [s for s in states.values() if isinstance(s, TimeState)] + datetime_states = [ + s for s in states.values() if isinstance(s, DateTimeState) + ] + pytest.fail( - f"Did not receive states from at least 50 sensors within 10 seconds. " - f"Received {len(sensor_states)} sensor states out of {len(states)} total states" + f"Did not receive expected states within 10 seconds. " + f"Received: {len(sensor_states)} sensor states (expected >=50), " + f"{len(date_states)} date states (expected >=1), " + f"{len(time_states)} time states (expected >=1), " + f"{len(datetime_states)} datetime states (expected >=1). " + f"Total states: {len(states)}" ) # Verify we received a good number of entity states @@ -64,13 +96,25 @@ async def test_host_mode_many_entities( if isinstance(s, SensorState) and isinstance(s.state, float) ] - assert sensor_count >= 50, ( - f"Expected at least 50 sensor states, got {sensor_count}" - ) assert len(sensor_states) >= 50, ( f"Expected at least 50 sensor states, got {len(sensor_states)}" ) + # Verify we received datetime entity states + date_states = [s for s in states.values() if isinstance(s, DateState)] + time_states = [s for s in states.values() if isinstance(s, TimeState)] + datetime_states = [s for s in states.values() if isinstance(s, DateTimeState)] + + assert len(date_states) >= 1, ( + f"Expected at least 1 date state, got {len(date_states)}" + ) + assert len(time_states) >= 1, ( + f"Expected at least 1 time state, got {len(time_states)}" + ) + assert len(datetime_states) >= 1, ( + f"Expected at least 1 datetime state, got {len(datetime_states)}" + ) + # Get entity info to verify climate entity details entities = await client.list_entities_services() climate_infos = [e for e in entities[0] if isinstance(e, ClimateInfo)] @@ -89,3 +133,28 @@ async def test_host_mode_many_entities( assert "HOME" in preset_names, f"Expected 'HOME' preset, got {preset_names}" assert "AWAY" in preset_names, f"Expected 'AWAY' preset, got {preset_names}" assert "SLEEP" in preset_names, f"Expected 'SLEEP' preset, got {preset_names}" + + # Verify datetime entities exist + date_infos = [e for e in entities[0] if isinstance(e, DateInfo)] + time_infos = [e for e in entities[0] if isinstance(e, TimeInfo)] + datetime_infos = [e for e in entities[0] if isinstance(e, DateTimeInfo)] + + assert len(date_infos) >= 1, "Expected at least 1 date entity" + assert len(time_infos) >= 1, "Expected at least 1 time entity" + assert len(datetime_infos) >= 1, "Expected at least 1 datetime entity" + + # Verify the entity names + date_info = date_infos[0] + assert date_info.name == "Test Date", ( + f"Expected date entity name 'Test Date', got {date_info.name}" + ) + + time_info = time_infos[0] + assert time_info.name == "Test Time", ( + f"Expected time entity name 'Test Time', got {time_info.name}" + ) + + datetime_info = datetime_infos[0] + assert datetime_info.name == "Test DateTime", ( + f"Expected datetime entity name 'Test DateTime', got {datetime_info.name}" + ) From 93b28447ee3532313e7f6dc29a1ef2c4dc7e1ed0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:13:55 -1000 Subject: [PATCH 58/60] [bluetooth_proxy] Optimize memory usage with fixed-size array and const string references (#10015) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 15 +++++++++------ .../components/bluetooth_proxy/bluetooth_proxy.h | 14 +++++++++----- .../components/esp32_ble_client/ble_client_base.h | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a59a33117a..97b0884dda 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,8 +35,8 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = this->connections_.size(); - this->connections_free_response_.free = this->connections_.size(); + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { @@ -134,12 +134,13 @@ void BluetoothProxy::dump_config() { ESP_LOGCONFIG(TAG, " Active: %s\n" " Connections: %d", - YESNO(this->active_), this->connections_.size()); + YESNO(this->active_), this->connection_count_); } void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() != 0 && !connection->disconnect_pending()) { connection->disconnect(); } @@ -162,7 +163,8 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == address) return connection; } @@ -170,7 +172,8 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese if (!reserve) return nullptr; - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == 0) { connection->send_service_ = DONE_SENDING_SERVICES; connection->set_address(address); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 70deef1ebd..d367dad438 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include @@ -63,8 +64,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { - this->connections_.push_back(connection); - connection->proxy_ = this; + if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; + } } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); @@ -138,8 +141,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; - // Group 2: Container types (typically 12 bytes on 32-bit) - std::vector connections_{}; + // Group 2: Fixed-size array of connection pointers + std::array connections_{}; // BLE advertisement batching std::vector advertisement_pool_; @@ -154,7 +157,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; - // 2 bytes used, 2 bytes padding + uint8_t connection_count_{0}; + // 3 bytes used, 1 byte padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index b30e9cd444..93260b1c15 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -66,7 +66,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { (uint8_t) (this->address_ >> 0) & 0xff); } } - std::string address_str() const { return this->address_str_; } + const std::string &address_str() const { return this->address_str_; } BLEService *get_service(espbt::ESPBTUUID uuid); BLEService *get_service(uint16_t uuid); From 52c450920851bece64c975222a7eff1fe3fec231 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:31:56 +1200 Subject: [PATCH 59/60] [esp32_dac] Always use esp-idf APIs (#9833) --- esphome/components/esp32_dac/esp32_dac.cpp | 21 +++------------------ esphome/components/esp32_dac/esp32_dac.h | 12 ++++-------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp32_dac/esp32_dac.cpp b/esphome/components/esp32_dac/esp32_dac.cpp index 7d8507c566..8f226a5cc2 100644 --- a/esphome/components/esp32_dac/esp32_dac.cpp +++ b/esphome/components/esp32_dac/esp32_dac.cpp @@ -2,11 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - -#ifdef USE_ARDUINO -#include -#endif +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) namespace esphome { namespace esp32_dac { @@ -23,18 +19,12 @@ void ESP32DAC::setup() { this->pin_->setup(); this->turn_off(); -#ifdef USE_ESP_IDF const dac_channel_t channel = this->pin_->get_pin() == DAC0_PIN ? DAC_CHAN_0 : DAC_CHAN_1; const dac_oneshot_config_t oneshot_cfg{channel}; dac_oneshot_new_channel(&oneshot_cfg, &this->dac_handle_); -#endif } -void ESP32DAC::on_safe_shutdown() { -#ifdef USE_ESP_IDF - dac_oneshot_del_channel(this->dac_handle_); -#endif -} +void ESP32DAC::on_safe_shutdown() { dac_oneshot_del_channel(this->dac_handle_); } void ESP32DAC::dump_config() { ESP_LOGCONFIG(TAG, "ESP32 DAC:"); @@ -48,15 +38,10 @@ void ESP32DAC::write_state(float state) { state = state * 255; -#ifdef USE_ESP_IDF dac_oneshot_output_voltage(this->dac_handle_, state); -#endif -#ifdef USE_ARDUINO - dacWrite(this->pin_->get_pin(), state); -#endif } } // namespace esp32_dac } // namespace esphome -#endif +#endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 63d0c914a1..95c687d307 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -1,15 +1,13 @@ #pragma once +#include "esphome/components/output/float_output.h" +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" -#include "esphome/components/output/float_output.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) -#ifdef USE_ESP_IDF #include -#endif namespace esphome { namespace esp32_dac { @@ -29,12 +27,10 @@ class ESP32DAC : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; -#ifdef USE_ESP_IDF dac_oneshot_handle_t dac_handle_; -#endif }; } // namespace esp32_dac } // namespace esphome -#endif +#endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 From 396c02c6de4bff1533f3fcbcf682a8ce61d1194a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:33:12 +1200 Subject: [PATCH 60/60] [core] Allow extra args on cli and just ignore them (#9814) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5e45b7f213..47e1c774ac 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -767,6 +767,12 @@ POST_CONFIG_ACTIONS = { "discover": command_discover, } +SIMPLE_CONFIG_ACTIONS = [ + "clean", + "clean-mqtt", + "config", +] + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) @@ -1032,6 +1038,13 @@ def parse_args(argv): arguments = argv[1:] argcomplete.autocomplete(parser) + + if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: + args, unknown_args = parser.parse_known_args(arguments) + if unknown_args: + _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) + return args + return parser.parse_args(arguments)