From 9c93d8925fa8a407e5ecbe84fe3bbe924df04841 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:41:48 -0400 Subject: [PATCH 001/597] [ota] Use PSA crypto for signature verification on ESP-IDF 6 (#18145) --- esphome/components/ota/ota_rsa_der.h | 74 ++++++++++++++ .../components/ota/ota_signature_esp_idf.cpp | 75 ++++++++++++++- tests/components/ota/test_rsa_der.cpp | 96 +++++++++++++++++++ 3 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 esphome/components/ota/ota_rsa_der.h create mode 100644 tests/components/ota/test_rsa_der.cpp diff --git a/esphome/components/ota/ota_rsa_der.h b/esphome/components/ota/ota_rsa_der.h new file mode 100644 index 0000000000..1ec4e3cc62 --- /dev/null +++ b/esphome/components/ota/ota_rsa_der.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ota { + +// The PSA Crypto API imports an RSA public key as a DER RSAPublicKey +// (RFC 3279 2.3.1), not as raw bignums: +// +// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } +// +// The Secure Boot v2 signature block stores the modulus and exponent raw, so +// they are wrapped here. Only RSA-3072 exists in that format, which fixes both +// headers: a 3072-bit modulus always has its top bit set, so its INTEGER is +// always tag + 2-byte length (0x181 = 385) + the sign pad; and the SEQUENCE +// body is always 392..396 bytes, so its header is always tag + 2-byte length. +// Only the exponent varies in width. +constexpr size_t RSA_3072_MODULUS_BYTES = 384; +constexpr uint8_t RSA_DER_MODULUS_PREFIX[] = {0x02, 0x82, 0x01, 0x81, 0x00}; +constexpr size_t RSA_DER_MODULUS_LEN = sizeof(RSA_DER_MODULUS_PREFIX) + RSA_3072_MODULUS_BYTES; // 389 +// 4-byte SEQUENCE header + modulus + the widest exponent INTEGER (tag, length, +// sign pad, 4 bytes). +constexpr size_t RSA_DER_PUBKEY_MAX = 4 + RSA_DER_MODULUS_LEN + 7; + +/// Wrap a raw RSA-3072 modulus and exponent as a DER RSAPublicKey. +/// +/// @param modulus_be Big-endian modulus, RSA_3072_MODULUS_BYTES long. +/// @param exponent_be Big-endian exponent, exponent_len bytes, leading zeros allowed. +/// Rejected if the significant bytes would not fit a short-form length. +/// @return the encoded length, or 0 if the exponent is zero or the buffer is too small. +inline size_t rsa_der_public_key(const uint8_t *modulus_be, const uint8_t *exponent_be, size_t exponent_len, + uint8_t *out, size_t out_len) { + // A DER INTEGER is signed: drop leading zero bytes, then prepend one back if + // the value would otherwise read as negative. + while (exponent_len > 0 && exponent_be[0] == 0x00) { + exponent_be++; + exponent_len--; + } + if (exponent_len == 0) { + return 0; // a zero exponent is not a usable key + } + const bool pad = (exponent_be[0] & 0x80) != 0; + const size_t exponent_content_len = exponent_len + (pad ? 1 : 0); + if (exponent_content_len > 0x7F) { + return 0; // would need a long-form length, which this encoder does not write + } + const size_t exponent_der_len = 2 + exponent_content_len; + const size_t body_len = RSA_DER_MODULUS_LEN + exponent_der_len; + const size_t total_len = 4 + body_len; + if (total_len > out_len) { + return 0; + } + + size_t i = 0; + out[i++] = 0x30; // SEQUENCE + out[i++] = 0x82; // 2-byte length follows + out[i++] = static_cast(body_len >> 8); + out[i++] = static_cast(body_len); + memcpy(out + i, RSA_DER_MODULUS_PREFIX, sizeof(RSA_DER_MODULUS_PREFIX)); + i += sizeof(RSA_DER_MODULUS_PREFIX); + memcpy(out + i, modulus_be, RSA_3072_MODULUS_BYTES); + i += RSA_3072_MODULUS_BYTES; + out[i++] = 0x02; // INTEGER + out[i++] = static_cast(exponent_content_len); + if (pad) { + out[i++] = 0x00; + } + memcpy(out + i, exponent_be, exponent_len); + return total_len; +} + +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index edee594bfe..b327988d2d 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -14,9 +14,20 @@ #include #include +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) made the legacy mbedtls_rsa_*/mbedtls_sha256_* headers +// private. Use the PSA Crypto API instead, like the sha256 component does. PSA +// crypto is auto-initialized by ESP-IDF at startup (esp_psa_crypto_init.c, +// priority 104), so no psa_crypto_init() call is needed. +#define USE_OTA_SIG_PSA +#include "ota_rsa_der.h" +#include +#else #include #include #include +#endif namespace esphome::ota { @@ -70,7 +81,14 @@ bool block_is_valid(const uint8_t *block) { } bool key_digest_of(const uint8_t *block, KeyDigest &out) { +#ifdef USE_OTA_SIG_PSA + size_t out_len = 0; + return psa_hash_compute(PSA_ALG_SHA_256, block + OFFSET_KEY, KEY_REGION_LEN, out.data(), out.size(), &out_len) == + PSA_SUCCESS && + out_len == out.size(); +#else return mbedtls_sha256(block + OFFSET_KEY, KEY_REGION_LEN, out.data(), /*is224=*/0) == 0; +#endif } // The offset of the signature sector: the app length rounded up to 4 KiB. @@ -93,20 +111,40 @@ bool signature_sector_offset(const esp_partition_t *part, size_t &out_offset) { // Returns false on a read or hash error so a hash failure is not later // misreported as a signature mismatch. bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t *out) { +#ifdef USE_OTA_SIG_PSA + psa_hash_operation_t ctx = PSA_HASH_OPERATION_INIT; + bool ok = psa_hash_setup(&ctx, PSA_ALG_SHA_256) == PSA_SUCCESS; +#else mbedtls_sha256_context ctx; mbedtls_sha256_init(&ctx); bool ok = mbedtls_sha256_starts(&ctx, /*is224=*/0) == 0; +#endif uint8_t buf[512]; for (size_t off = 0; ok && off < image_padded_len; off += sizeof(buf)) { size_t chunk = std::min(sizeof(buf), image_padded_len - off); - if (esp_partition_read(part, off, buf, chunk) != ESP_OK || mbedtls_sha256_update(&ctx, buf, chunk) != 0) { + if (esp_partition_read(part, off, buf, chunk) != ESP_OK) { ok = false; + break; } +#ifdef USE_OTA_SIG_PSA + ok = psa_hash_update(&ctx, buf, chunk) == PSA_SUCCESS; +#else + ok = mbedtls_sha256_update(&ctx, buf, chunk) == 0; +#endif } +#ifdef USE_OTA_SIG_PSA + size_t out_len = 0; + if (ok) { + ok = psa_hash_finish(&ctx, out, SHA256_BYTES, &out_len) == PSA_SUCCESS && out_len == SHA256_BYTES; + } + // A no-op once the operation has been finished + psa_hash_abort(&ctx); +#else if (ok) { ok = mbedtls_sha256_finish(&ctx, out) == 0; } mbedtls_sha256_free(&ctx); +#endif return ok; } @@ -114,6 +152,7 @@ bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t // block's modulus and signature are stored little-endian; reverse them in place // -- block is the caller's scratch buffer, overwritten on the next iteration -- // rather than stacking a second 384-byte copy of each bignum. + bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { std::reverse(block + OFFSET_MODULUS, block + OFFSET_MODULUS + RSA_3072_BYTES); std::reverse(block + OFFSET_SIGNATURE, block + OFFSET_SIGNATURE + RSA_3072_BYTES); @@ -122,22 +161,48 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { uint8_t exponent_be[4] = {static_cast(exponent_le >> 24), static_cast(exponent_le >> 16), static_cast(exponent_le >> 8), static_cast(exponent_le)}; +#ifdef USE_OTA_SIG_PSA + static_assert(RSA_3072_BYTES == RSA_3072_MODULUS_BYTES, "signature block and DER encoder disagree on modulus size"); + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t der_len = rsa_der_public_key(block + OFFSET_MODULUS, exponent_be, sizeof(exponent_be), der, sizeof(der)); + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attr, PSA_KEY_TYPE_RSA_PUBLIC_KEY); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_VERIFY_HASH); + // ANY_SALT preserves the salt-length acceptance of mbedtls_rsa_rsassa_pss_verify(), + // which this replaces; espsecure signs with a 32-byte salt. TF-PSA-Crypto defines + // PSA_WANT_ALG_RSA_PSS_ANY_SALT from PSA_WANT_ALG_RSA_PSS, which IDF enables. + psa_set_key_algorithm(&attr, PSA_ALG_RSA_PSS_ANY_SALT(PSA_ALG_SHA_256)); + mbedtls_svc_key_id_t key = MBEDTLS_SVC_KEY_ID_INIT; + const bool key_ok = der_len != 0 && psa_import_key(&attr, der, der_len, &key) == PSA_SUCCESS; +#else mbedtls_rsa_context rsa; mbedtls_rsa_init(&rsa); - bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, nullptr, 0, - exponent_be, sizeof(exponent_be)) == 0 && - mbedtls_rsa_complete(&rsa) == 0 && - mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0; + const bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, + nullptr, 0, exponent_be, sizeof(exponent_be)) == 0 && + mbedtls_rsa_complete(&rsa) == 0 && + mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0; +#endif bool verified = false; if (!key_ok) { // A setup/allocation failure (e.g. OOM right after the download) is not a // signature mismatch -- log it distinctly so it isn't read as "wrong key". OTA_IDF_SIG_LOG(ESP_LOGE, "RSA key setup failed"); } else { +#ifdef USE_OTA_SIG_PSA + verified = psa_verify_hash(key, PSA_ALG_RSA_PSS_ANY_SALT(PSA_ALG_SHA_256), digest, SHA256_BYTES, + block + OFFSET_SIGNATURE, RSA_3072_BYTES) == PSA_SUCCESS; +#else verified = mbedtls_rsa_rsassa_pss_verify(&rsa, MBEDTLS_MD_SHA256, SHA256_BYTES, digest, block + OFFSET_SIGNATURE) == 0; +#endif } +#ifdef USE_OTA_SIG_PSA + if (key_ok) { + psa_destroy_key(key); + } +#else mbedtls_rsa_free(&rsa); +#endif return verified; } diff --git a/tests/components/ota/test_rsa_der.cpp b/tests/components/ota/test_rsa_der.cpp new file mode 100644 index 0000000000..aefce6769a --- /dev/null +++ b/tests/components/ota/test_rsa_der.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/ota/ota_rsa_der.h" + +namespace esphome::ota::testing { + +namespace { + +// A modulus with the top bit set, as every real 3072-bit modulus has. +std::array make_modulus(uint8_t first = 0xC5) { + std::array modulus{}; + modulus.fill(0xAB); + modulus[0] = first; + modulus[RSA_3072_MODULUS_BYTES - 1] = 0x01; // odd, like a real modulus + return modulus; +} + +} // namespace + +// e = 65537, the exponent espsecure uses. +TEST(RsaDerPublicKey, StandardExponent) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x01, 0x00, 0x01}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + // 4 (SEQUENCE header) + 389 (modulus) + 5 (exponent) = 398 + ASSERT_EQ(len, 398u); + // SEQUENCE, 2-byte length of the 394-byte body + EXPECT_EQ(der[0], 0x30); + EXPECT_EQ(der[1], 0x82); + EXPECT_EQ((der[2] << 8) | der[3], 394); + // INTEGER, 2-byte length 385, sign pad, then the modulus + EXPECT_EQ(der[4], 0x02); + EXPECT_EQ(der[5], 0x82); + EXPECT_EQ((der[6] << 8) | der[7], 385); + EXPECT_EQ(der[8], 0x00); + EXPECT_EQ(0, memcmp(der + 9, modulus.data(), modulus.size())); + // INTEGER, 3 bytes, leading zero of the input dropped + const size_t exp_at = 9 + RSA_3072_MODULUS_BYTES; + EXPECT_EQ(der[exp_at], 0x02); + EXPECT_EQ(der[exp_at + 1], 0x03); + EXPECT_EQ(der[exp_at + 2], 0x01); + EXPECT_EQ(der[exp_at + 3], 0x00); + EXPECT_EQ(der[exp_at + 4], 0x01); +} + +// An exponent whose top bit is set needs a 0x00 sign pad, widening the body. +TEST(RsaDerPublicKey, ExponentNeedingSignPad) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x00, 0x00, 0x81}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + ASSERT_EQ(len, 397u); // 4 + 389 + 4 + const size_t exp_at = 9 + RSA_3072_MODULUS_BYTES; + EXPECT_EQ(der[exp_at], 0x02); + EXPECT_EQ(der[exp_at + 1], 0x02); // pad + one value byte + EXPECT_EQ(der[exp_at + 2], 0x00); + EXPECT_EQ(der[exp_at + 3], 0x81); +} + +// The widest exponent still fits the documented buffer size. +TEST(RsaDerPublicKey, WidestExponentFitsBuffer) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + const size_t len = rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)); + + ASSERT_EQ(len, RSA_DER_PUBKEY_MAX); // 4 + 389 + 7 + EXPECT_LE(len, sizeof(der)); +} + +TEST(RsaDerPublicKey, ZeroExponentRejected) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x00, 0x00, 0x00}; + uint8_t der[RSA_DER_PUBKEY_MAX]; + EXPECT_EQ(rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der, sizeof(der)), 0u); +} + +// A buffer that cannot hold the result must be refused, not overrun. Sized +// against a heap vector so ASAN catches a write past the end. +TEST(RsaDerPublicKey, ShortBufferRejected) { + const auto modulus = make_modulus(); + const uint8_t exponent[4] = {0x00, 0x01, 0x00, 0x01}; + for (size_t out_len : {size_t(0), size_t(1), size_t(4), size_t(100), size_t(397)}) { + std::vector der(out_len); + EXPECT_EQ(rsa_der_public_key(modulus.data(), exponent, sizeof(exponent), der.data(), out_len), 0u) + << "out_len=" << out_len; + } +} + +} // namespace esphome::ota::testing From 2f2634bf6bfe49f1a88ac24ad03972c77925cfdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 08:54:28 -0500 Subject: [PATCH 002/597] [bluetooth_connection] Move BluetoothConnection out of bluetooth_proxy (#18129) --- CODEOWNERS | 1 + .../bluetooth_connection/__init__.py | 46 +++++++++++++++++++ .../bluetooth_connection.h | 37 +++++++++++++++ .../bluetooth_connection_esp32.cpp} | 22 +++++---- .../bluetooth_connection_esp32.h} | 16 +++++-- .../components/bluetooth_proxy/__init__.py | 19 ++++---- .../bluetooth_proxy/bluetooth_proxy.cpp | 24 +++++----- .../bluetooth_proxy/bluetooth_proxy.h | 40 +++++++--------- .../bluetooth_proxy/test_platform_gates.py | 18 +++++++- 9 files changed, 163 insertions(+), 60 deletions(-) create mode 100644 esphome/components/bluetooth_connection/__init__.py create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection.h rename esphome/components/{bluetooth_proxy/bluetooth_connection.cpp => bluetooth_connection/bluetooth_connection_esp32.cpp} (98%) rename esphome/components/{bluetooth_proxy/bluetooth_connection.h => bluetooth_connection/bluetooth_connection_esp32.h} (86%) diff --git a/CODEOWNERS b/CODEOWNERS index d2e26edca3..9bcbe087c5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -78,6 +78,7 @@ esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/ble_device_base/* @Bl00d-B0b esphome/components/ble_nus/* @tomaszduda23 +esphome/components/bluetooth_connection/* @bdraco @jesserockz esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bm8563/* @abmantis esphome/components/bme280_base/* @esphome/core diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..1e85c4b8f9 --- /dev/null +++ b/esphome/components/bluetooth_connection/__init__.py @@ -0,0 +1,46 @@ +"""Per-platform GATT connection backends the Bluetooth proxy drives. + +Auto-loaded by bluetooth_proxy, no user-facing configuration; the proxy's +codegen declares and registers the connection instances. +""" + +import functools + +import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform +from esphome.const import PlatformFramework +from esphome.core import CORE + + +def AUTO_LOAD() -> list[str]: + """The esp32 connection header includes esp32_ble_client, so the closure + must be self-satisfying; no target platform (tooling) gets the union.""" + if CORE.is_esp32 or CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_client"] + return ["ble_device_base"] + + +CODEOWNERS = ["@bdraco", "@jesserockz"] + +bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") + + +@functools.cache +def esp32_connection_class() -> cg.MockObjClass: + """Lazy: importing esp32_ble_client registers esp32-only automations as + an import side effect, which must not leak into other platforms.""" + from esphome.components import esp32_ble_client + + return bluetooth_connection_ns.class_( + "BluetoothConnection", esp32_ble_client.BLEClientBase + ) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "bluetooth_connection_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h new file mode 100644 index 0000000000..f63fb93492 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -0,0 +1,37 @@ +// Shared types for the per-platform GATT connection backends and the +// Bluetooth proxy that drives them. + +#pragma once + +#include "esphome/core/defines.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" + +#ifdef USE_ESP32 +#include +#endif + +namespace esphome::bluetooth_connection { + +// Connection-owned error type for the API error fields, which are plain +// integers on the wire. Aliases esp_err_t on esp32 (where the values come from +// IDF calls); a bare int elsewhere. Owning the name instead of probing for +// esp_err_t keeps the header independent of how a platform's SDK spells its +// error type. +#ifdef USE_ESP32 +using conn_err_t = esp_err_t; +static constexpr conn_err_t CONN_OK = ESP_OK; +#else +using conn_err_t = int; +static constexpr conn_err_t CONN_OK = 0; +#endif + +// The ESPHome-private "not connected" wire value, shared with the neutral +// GATT contract so backend and wrapper cannot drift. +static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; + +// send_service_ cursor states; >= 0 is the next service index to stream. +static constexpr int DONE_SENDING_SERVICES = -2; +static constexpr int INIT_SENDING_SERVICES = -3; + +} // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp similarity index 98% rename from esphome/components/bluetooth_proxy/bluetooth_connection.cpp rename to esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 9820977a13..5274637b66 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -1,4 +1,4 @@ -#include "bluetooth_connection.h" +#include "bluetooth_connection_esp32.h" #include "esphome/components/api/api_pb2.h" #include "esphome/core/helpers.h" @@ -6,11 +6,13 @@ #ifdef USE_ESP32 -#include "bluetooth_proxy.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" -namespace esphome::bluetooth_proxy { +namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_proxy.connection"; +namespace espbt = esphome::esp32_ble_tracker; + +static const char *const TAG = "bluetooth_connection"; // This function is allocation-free and directly packs UUIDs into the output array // using precalculated constants for the Bluetooth base UUID @@ -516,7 +518,7 @@ void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_bl esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { if (!this->connected()) { this->log_gatt_not_connected_("read", "characteristic"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -529,7 +531,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "characteristic"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -545,7 +547,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { if (!this->connected()) { this->log_gatt_not_connected_("read", "descriptor"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -556,7 +558,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "descriptor"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -572,7 +574,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t * esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { if (!this->connected()) { this->log_gatt_not_connected_("notify", "characteristic"); - return ESP_GATT_NOT_CONNECTED; + return GATT_NOT_CONNECTED; } if (enable) { @@ -592,6 +594,6 @@ esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisemen return this->proxy_->get_advertisement_parser_type(); } -} // namespace esphome::bluetooth_proxy +} // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h similarity index 86% rename from esphome/components/bluetooth_proxy/bluetooth_connection.h rename to esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index e5600f6af4..65e2d0777e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -1,12 +1,18 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 #include "esphome/components/esp32_ble_client/ble_client_base.h" -namespace esphome::bluetooth_proxy { +#include "bluetooth_connection.h" +namespace esphome::bluetooth_proxy { class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { class BluetoothConnection final : public esp32_ble_client::BLEClientBase { public: @@ -31,7 +37,7 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { void set_address(uint64_t address) override; protected: - friend class BluetoothProxy; + friend class bluetooth_proxy::BluetoothProxy; void on_disconnect_complete(esp_err_t reason) override; @@ -47,16 +53,16 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) - BluetoothProxy *proxy_; + bluetooth_proxy::BluetoothProxy *proxy_; // Group 2: 2-byte types - int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index + int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states // Group 3: 1-byte types bool seen_mtu_or_services_{false}; // 1 byte used, 1 byte padding }; -} // namespace esphome::bluetooth_proxy +} // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index bb05f1b21f..5916132ab2 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -2,7 +2,7 @@ import functools import logging import esphome.codegen as cg -from esphome.components import ble_device_base +from esphome.components import ble_device_base, bluetooth_connection import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2 from esphome.core import CORE @@ -27,13 +27,18 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: target platform set, so it takes one of the concrete branches. """ if CORE.is_esp32: - return ["esp32_ble_client", "esp32_ble_tracker"] + return ["bluetooth_connection", "esp32_ble_client", "esp32_ble_tracker"] if CORE.target_platform in _HUB_PLATFORMS: - return ["ble_device_base"] + return ["ble_device_base", "bluetooth_connection"] # No target platform, or one this component does not support: tooling # resolving the manifest (including the host-pinned dependency resolver) — # expose every arm so the closure keeps the esp32 BLE stack. - return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"] + return [ + "ble_device_base", + "bluetooth_connection", + "esp32_ble_client", + "esp32_ble_tracker", + ] # Platforms with an in-tree ble_device_base BLE tracker hub whose controller @@ -67,7 +72,7 @@ _IDF_MAX_CONNECTIONS = 9 @functools.cache def _esp32_config_schema() -> cv.All: """Build the esp32 schema, importing the esp32 BLE stack only when used.""" - from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker + from esphome.components import esp32_ble, esp32_ble_tracker if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS: raise cv.Invalid( @@ -77,9 +82,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - BluetoothConnection = bluetooth_proxy_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase - ) + BluetoothConnection = bluetooth_connection.esp32_connection_class() CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(BluetoothConnection), diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index e681030611..08b58fc3b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -124,7 +124,7 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); + this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); } #ifdef USE_ESP32 @@ -438,7 +438,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", connection ? static_cast(connection->connection_index_) : -1, connection ? connection->address_str() : "unknown"); - resp.error = ESP_GATT_NOT_CONNECTED; + resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); return; } @@ -498,7 +498,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: ESP_LOGW(TAG, "Active connections are not supported on this platform"); - this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED); + this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: // Not an error: the device is already disconnected, which is the requested state. @@ -506,13 +506,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_connections_free(); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: - this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED); + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: - this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED); + this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); break; case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: - this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED); + this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); break; } } @@ -546,7 +546,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn return; api::BluetoothSetConnectionParamsResponse resp; resp.address = msg.address; - resp.error = ESP_GATT_NOT_CONNECTED; + resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); } @@ -605,7 +605,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti #endif } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) { +void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceConnectionResponse call; @@ -633,7 +633,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) { this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) { +void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothGATTErrorResponse call; @@ -643,7 +643,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_er this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) { +void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDevicePairingResponse call; @@ -654,7 +654,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_er this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) { +void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceUnpairingResponse call; @@ -667,7 +667,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy // Shared by both platform paths: the neutral bluetooth_device_request() uses it to // answer a clear-cache request with a clean error, so it must not be esp32-guarded. -void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) { +void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDeviceClearCacheResponse call; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index fd1f1839c9..dbfc119d98 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -13,13 +13,13 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" #ifdef USE_ESP32 #include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -#include "bluetooth_connection.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include @@ -31,23 +31,16 @@ namespace esphome::bluetooth_proxy { -// Proxy-owned error type for the API error fields, which are plain integers on -// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls); -// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps -// the header independent of how a hub platform's SDK spells its error type. -#ifdef USE_ESP32 -using proxy_err_t = esp_err_t; -static constexpr proxy_err_t PROXY_OK = ESP_OK; -#else -using proxy_err_t = int; -static constexpr proxy_err_t PROXY_OK = 0; -#endif - -static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; -static constexpr int DONE_SENDING_SERVICES = -2; -static constexpr int INIT_SENDING_SERVICES = -3; +// The connection-domain types live in the bluetooth_connection component; +// re-exported here so the proxy code reads unqualified. +using bluetooth_connection::CONN_OK; +using bluetooth_connection::conn_err_t; +using bluetooth_connection::DONE_SENDING_SERVICES; +using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::INIT_SENDING_SERVICES; #ifdef USE_ESP32 +using BluetoothConnection = bluetooth_connection::BluetoothConnection; using namespace esp32_ble_client; #endif @@ -79,7 +72,8 @@ enum BluetoothProxySubscriptionFlag : uint32_t { class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public esp32_ble_tracker::BLEScannerStateListener, public Component { - friend class BluetoothConnection; // Allow connection to update connections_free_response_ + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; #else class BluetoothProxy final : public Component { #endif @@ -129,14 +123,14 @@ class BluetoothProxy final : public Component { 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, proxy_err_t error = PROXY_OK); + void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_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, proxy_err_t error); - void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK); - void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK); - void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK); + void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); + void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); + void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); void bluetooth_scanner_set_mode(bool active); diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 4d7997fbce..c5240105fa 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -5,12 +5,12 @@ advertisement-only arm applies its own defaults.""" import pytest from esphome import config_validation as cv -from esphome.components import bluetooth_proxy +from esphome.components import bluetooth_connection, bluetooth_proxy from esphome.const import CONF_ACTIVE, KEY_TARGET_PLATFORM from esphome.core import CORE, KEY_CORE -def _set_platform(platform: str) -> None: +def _set_platform(platform: str | None) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform @@ -46,3 +46,17 @@ def test_hub_platform_accepts_the_advertisement_only_shape() -> None: _set_platform("ln882x") validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False + + +def test_bluetooth_connection_auto_load_covers_its_includes() -> None: + # The esp32 connection header includes esp32_ble_client; the auto load + # must satisfy that closure itself (regression: it once relied on the + # consumer's auto loads). + _set_platform("esp32") + assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() + _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] + # No target platform (tooling resolving the manifest): the union, so + # dependency closures stay complete for build_codeowners and friends. + _set_platform(None) + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] From 8b0e23d55b9022b0307cfe4bec3fbecff39c57fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 17:30:00 +0300 Subject: [PATCH 003/597] [bluetooth_proxy] Fold scanner-state bookkeeping into the sender; extend platform-gate tests (#18150) Co-authored-by: J. Nick Koston --- .../bluetooth_proxy/bluetooth_proxy.cpp | 20 ++--- .../bluetooth_proxy/test_platform_gates.py | 81 +++++++++++++++---- 2 files changed, 75 insertions(+), 26 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 08b58fc3b4..9002727bbf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -93,9 +93,13 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } void BluetoothProxy::send_bluetooth_scanner_state_() { + // Records what goes on the wire so loop()'s change detector cannot report the + // same transition twice; every caller relies on this instead of updating + // last_scan_running_ itself. + this->last_scan_running_ = this->hub_->scan_running(); api::BluetoothScannerStateResponse resp; - resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; + resp.state = this->last_scan_running_ ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING + : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ @@ -483,9 +487,7 @@ void BluetoothProxy::loop() { return; // The hub has no scanner-state listener interface; poll and report on change. - bool running = this->hub_->scan_running(); - if (running != this->last_scan_running_) { - this->last_scan_running_ = running; + if (this->hub_->scan_running() != this->last_scan_running_) { this->send_bluetooth_scanner_state_(); } @@ -561,10 +563,9 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { } } if (this->api_connection_ != nullptr) { - // Keep loop()'s change detector in step with the state sent here, so a - // failed restart (scan_running_ dropped by the tracker) is not reported - // twice — once now and again on the next tick. - this->last_scan_running_ = this->hub_->scan_running(); + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. this->send_bluetooth_scanner_state_(); } } @@ -589,7 +590,6 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->parent_->recalculate_advertisement_parser_types(); this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); #else - this->last_scan_running_ = this->hub_->scan_running(); this->send_bluetooth_scanner_state_(); #endif } diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index c5240105fa..682f98bf6b 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -6,44 +6,93 @@ import pytest from esphome import config_validation as cv from esphome.components import bluetooth_connection, bluetooth_proxy -from esphome.const import CONF_ACTIVE, KEY_TARGET_PLATFORM -from esphome.core import CORE, KEY_CORE +from esphome.const import ( + CONF_ACTIVE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PlatformFramework, +) +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2_ARDUINO, +] def _set_platform(platform: str | None) -> None: + # For arms set_core_config cannot express (bare platform, no framework). CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform -def test_ble_less_platform_gets_the_real_reason() -> None: - _set_platform("esp8266") +def test_ble_less_platform_gets_the_real_reason( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) with pytest.raises(cv.Invalid, match="not supported on esp8266"): bluetooth_proxy.CONFIG_SCHEMA({}) -def test_ble_less_platform_connection_keys_fall_through() -> None: +def test_ble_less_platform_connection_keys_fall_through( + set_core_config: SetCoreConfigCallable, +) -> None: # The key-level rejection must not fire here — it would imply an # advertisement-only proxy exists on this platform. - _set_platform("esp8266") + set_core_config(PlatformFramework.ESP8266_ARDUINO) with pytest.raises(cv.Invalid, match="not supported on esp8266"): bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) -def test_hub_platform_rejects_active() -> None: - _set_platform("ln882x") +def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None: + # set_core_config cannot express "no platform"; script/build_codeowners.py + # sets exactly this shape, and the key gate returns early on it so the + # platform gate is what reports. + CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None} + with pytest.raises(cv.Invalid, match="not supported on None"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + + +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_rejects_active( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) with pytest.raises(cv.Invalid, match="Active connections are not supported"): bluetooth_proxy.CONFIG_SCHEMA({"active": True}) -def test_hub_platform_rejects_connection_keys_by_name() -> None: - _set_platform("ln882x") - with pytest.raises(cv.Invalid, match="'connection_slots' requires active"): - bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) - with pytest.raises(cv.Invalid, match="'cache_services' requires active"): - bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +@pytest.mark.parametrize( + ("key", "value"), + [ + ("connection_slots", 2), + ("cache_services", True), + # Absent from the outer CONFIG_SCHEMA, so this gate is the only test + # that touches it. + ("connections", [{}]), + ], +) +def test_hub_platform_rejects_connection_keys_by_name( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + key: str, + value: object, +) -> None: + set_core_config(platform_framework) + with pytest.raises(cv.Invalid, match=f"'{key}' requires active"): + bluetooth_proxy.CONFIG_SCHEMA({key: value}) -def test_hub_platform_accepts_the_advertisement_only_shape() -> None: - _set_platform("ln882x") +@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS) +def test_hub_platform_accepts_the_advertisement_only_shape( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + set_core_config(platform_framework) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False From 1bbe8b415faa09118cd6da2f9fe6fceaab9d6f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 18:01:50 +0300 Subject: [PATCH 004/597] [bluetooth_proxy] Only advance the scanner-state detector when the frame was sent (#18154) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 17 +++++++++-------- .../bluetooth_proxy/test_platform_gates.py | 7 +++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 9002727bbf..66f22c9a90 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -59,7 +59,6 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); - this->last_scan_running_ = this->hub_->scan_running(); // The hub delivers raw advertisements on the ESPHome main loop: // mac is least-significant octet first (BLE controller convention). @@ -93,19 +92,21 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } void BluetoothProxy::send_bluetooth_scanner_state_() { - // Records what goes on the wire so loop()'s change detector cannot report the - // same transition twice; every caller relies on this instead of updating - // last_scan_running_ itself. - this->last_scan_running_ = this->hub_->scan_running(); + // One read feeds both the frame and the change detector; the detector only + // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a + // full TX buffer) is retried from loop() instead of leaving a stale state. + const bool running = this->hub_->scan_running(); api::BluetoothScannerStateResponse resp; - resp.state = this->last_scan_running_ ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; + resp.state = running ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING + : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + if (this->api_connection_->send_message(resp)) { + this->last_scan_running_ = running; + } } #endif // USE_ESP32 diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 682f98bf6b..c474b5fa81 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -23,6 +23,13 @@ HUB_PLATFORM_FRAMEWORKS = [ ] +def test_hub_platform_list_covers_every_hub_platform() -> None: + # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise + # get no gate coverage at all. + covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} + assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + + def _set_platform(platform: str | None) -> None: # For arms set_core_config cannot express (bare platform, no framework). CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform From b8027409974c386495a0be59f6f7c637daed2306 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Fri, 7 Aug 2026 18:11:29 +0300 Subject: [PATCH 005/597] [ld6002b] Add switch, number and text sensor platforms (3/5) (#17821) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/const.py | 10 + esphome/components/ld6002b/ld6002b.cpp | 591 +++++++++++++++++- esphome/components/ld6002b/ld6002b.h | 105 ++++ esphome/components/ld6002b/number/__init__.py | 82 +++ .../ld6002b/number/ld6002b_number.cpp | 10 + .../ld6002b/number/ld6002b_number.h | 18 + esphome/components/ld6002b/sensor.py | 9 + esphome/components/ld6002b/switch/__init__.py | 60 ++ .../ld6002b/switch/ld6002b_switch.cpp | 10 + .../ld6002b/switch/ld6002b_switch.h | 18 + esphome/components/ld6002b/text_sensor.py | 31 + tests/components/ld6002b/common.yaml | 32 + 12 files changed, 949 insertions(+), 27 deletions(-) create mode 100644 esphome/components/ld6002b/number/__init__.py create mode 100644 esphome/components/ld6002b/number/ld6002b_number.cpp create mode 100644 esphome/components/ld6002b/number/ld6002b_number.h create mode 100644 esphome/components/ld6002b/switch/__init__.py create mode 100644 esphome/components/ld6002b/switch/ld6002b_switch.cpp create mode 100644 esphome/components/ld6002b/switch/ld6002b_switch.h create mode 100644 esphome/components/ld6002b/text_sensor.py diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index 4419a92d23..9f9227988e 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,8 +1,18 @@ CONF_AUTO_WAKE = "auto_wake" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_HOLD_DELAY = "hold_delay" CONF_LD6002B_ID = "ld6002b_id" +CONF_LOW_POWER = "low_power" +CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" +CONF_OTA_VERSION = "ota_version" +CONF_POINT_CLOUD = "point_cloud" +CONF_POINT_COUNT = "point_count" +CONF_TARGET_DISPLAY = "target_display" CONF_WAKEUP_PULSE = "wakeup_pulse" +CONF_WORK_MODE = "work_mode" CONF_Z = "z" +CONF_Z_MAX = "z_max" +CONF_Z_MIN = "z_min" MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 2a09e98c25..54979fe9eb 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace esphome::ld6002b { @@ -14,20 +15,40 @@ static constexpr uint32_t SETUP_DELAY_MS = 100; // Command/message types static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; +static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; +static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; +static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; +static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; // Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_GET_DELAY = 0x05; static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; +static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; +static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; +static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; + #ifdef ESPHOME_LOG_HAS_VERBOSE static const char *control_command_name(uint32_t command) { switch (command) { + case CMD_GET_DELAY: + return "get_delay"; case CMD_POINT_CLOUD_ON: return "point_cloud_on"; case CMD_POINT_CLOUD_OFF: @@ -36,10 +57,68 @@ static const char *control_command_name(uint32_t command) { return "target_display_on"; case CMD_TARGET_DISPLAY_OFF: return "target_display_off"; + case CMD_GET_Z_RANGE: + return "get_z_range"; + case CMD_LOW_POWER_ON: + return "low_power_on"; + case CMD_LOW_POWER_OFF: + return "low_power_off"; + case CMD_GET_LOW_POWER: + return "get_low_power"; + case CMD_GET_LOW_POWER_SLEEP: + return "get_low_power_sleep"; default: return "unknown"; } } + +static const char *frame_type_name(uint16_t type) { + switch (type) { + case TYPE_CONTROL: + return "control"; + case TYPE_SET_HOLD_DELAY: + return "set_hold_delay"; + case TYPE_SET_Z_RANGE: + return "set_z_range"; + case TYPE_SET_LOW_POWER_SLEEP: + return "set_low_power_sleep"; + case TYPE_REPORT_TARGET: + return "report_target"; + case TYPE_REPORT_POINT_CLOUD: + return "report_point_cloud"; + case TYPE_REPORT_DELAY: + return "report_delay"; + case TYPE_REPORT_Z_RANGE: + return "report_z_range"; + case TYPE_REPORT_LOW_POWER: + return "report_low_power"; + case TYPE_REPORT_LOW_POWER_SLEEP: + return "report_low_power_sleep"; + case TYPE_REPORT_WORK_MODE: + return "report_work_mode"; + case TYPE_QUERY_VERSION: + return "query_version"; + default: + return "unknown"; + } +} + +static bool is_expected_control_report(uint32_t command, uint16_t type) { + switch (command) { + case CMD_GET_DELAY: + return type == TYPE_REPORT_DELAY; + case CMD_GET_Z_RANGE: + return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_LOW_POWER: + case CMD_LOW_POWER_ON: + case CMD_LOW_POWER_OFF: + return type == TYPE_REPORT_LOW_POWER; + case CMD_GET_LOW_POWER_SLEEP: + return type == TYPE_REPORT_LOW_POWER_SLEEP; + default: + return false; + } +} #endif uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } @@ -70,10 +149,25 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[3] = (value >> 24) & 0xFF; } +void LD6002BComponent::write_f32_le(uint8_t *data, float value) { + uint32_t raw; + std::memcpy(&raw, &value, sizeof(raw)); + write_u32_le(data, raw); +} + void LD6002BComponent::setup() { + // Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup. + bool point_cloud_configured = false; +#ifdef USE_SENSOR + point_cloud_configured = point_cloud_configured || this->point_count_sensor_ != nullptr; +#endif +#ifdef USE_SWITCH + point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr; +#endif + this->max_data_len_ = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN; // One allocation for the component lifetime; the parser reuses it for the header and every payload. RAMAllocator allocator; - this->data_buf_ = allocator.allocate(DEFAULT_MAX_DATA_LEN); + this->data_buf_ = allocator.allocate(this->max_data_len_); if (this->data_buf_ == nullptr) { this->mark_failed(LOG_STR("Failed to allocate frame buffer")); return; @@ -108,25 +202,120 @@ void LD6002BComponent::setup() { } } #endif - if (want_target_stream) { - this->send_control_command_(CMD_TARGET_DISPLAY_ON); +#ifdef USE_TEXT_SENSOR + // The work mode fallback reads presence off this stream, so it counts as a + // consumer of it here. This only feeds the automatic branch below: with a + // target_display switch configured that switch still decides, and the + // fallback weighs no presence at all while the stream is off. + want_target_stream = want_target_stream || this->work_mode_text_sensor_ != nullptr; +#endif + bool target_display_controlled = false; +#ifdef USE_SWITCH + if (this->target_display_switch_ != nullptr) { + target_display_controlled = true; + // Nothing reports this switch back, so its restored state is the only state + // there is. Restoring through the switch keeps its inversion in the path: + // the restored value is logical, and turn_on()/turn_off() are what turn it + // into the raw command, the published state and the stream flag. + const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); + if (state) { + this->target_display_switch_->turn_on(); + } else { + this->target_display_switch_->turn_off(); + } + } +#endif + if (!target_display_controlled) { + // No switch: the stream follows its consumers. With none, nothing is sent + // and the module's own default stands -- but the reports are gated out + // regardless, because there is nothing configured for them to feed. + this->target_display_enabled_ = want_target_stream; + if (want_target_stream) { + this->send_control_command_(CMD_TARGET_DISPLAY_ON); + } } - this->send_control_command_(CMD_POINT_CLOUD_OFF); + bool point_cloud_controlled = false; +#ifdef USE_SWITCH + if (this->point_cloud_switch_ != nullptr) { + point_cloud_controlled = true; + // The switch owns the stream, so it is also what applies the restored state: + // driving it rather than the module keeps the entity's inversion in the path. + const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->point_cloud_switch_->turn_on(); + } else { + this->point_cloud_switch_->turn_off(); + } + } +#endif + if (!point_cloud_controlled) { + // No switch: the stream follows the sensor that reads it, which is also what + // the frame buffer above was sized for. + bool want_point_cloud = false; +#ifdef USE_SENSOR + want_point_cloud = this->point_count_sensor_ != nullptr; +#endif + this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); + this->point_cloud_enabled_ = want_point_cloud; + } +#ifdef USE_NUMBER + if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { + this->send_control_command_(CMD_GET_Z_RANGE); + } + if (this->low_power_sleep_number_ != nullptr) { + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + } + if (this->hold_delay_number_ != nullptr) { + this->send_control_command_(CMD_GET_DELAY); + } +#endif +#ifdef USE_SWITCH + bool want_low_power = this->low_power_switch_ != nullptr; + if (want_low_power) { + // The module reports this one back, so the query below confirms what it took. + // Driving the switch applies its inversion; it also marks the restored value + // as reported, so the work mode fallback runs on that until the query lands. + const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->low_power_switch_->turn_on(); + } else { + this->low_power_switch_->turn_off(); + } + } +#else + bool want_low_power = false; +#endif +#ifdef USE_TEXT_SENSOR + want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr; +#endif + if (want_low_power) { + this->send_control_command_(CMD_GET_LOW_POWER); + } + + this->init_version_pref_(); + +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ != nullptr) { + this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA)); + } +#endif }); } void LD6002BComponent::dump_config() { ESP_LOGCONFIG(TAG, "HLK-LD6002B:\n" - " Auto wake: %s", - this->auto_wake_ ? "true" : "false"); + " Auto wake: %s\n" + " Max data length: %u", + this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); + LOG_SENSOR(" ", "Point Count", this->point_count_sensor_); for (auto &target : this->targets_) { LOG_SENSOR(" ", "Target X", target.x); LOG_SENSOR(" ", "Target Y", target.y); @@ -141,6 +330,21 @@ void LD6002BComponent::dump_config() { LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); } #endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); + LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_); +#endif +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_); + LOG_NUMBER(" ", "Z Min", this->z_min_number_); + LOG_NUMBER(" ", "Z Max", this->z_max_number_); + LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); +#endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Low Power", this->low_power_switch_); + LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); + LOG_SWITCH(" ", "Target Display", this->target_display_switch_); +#endif } void LD6002BComponent::loop() { @@ -192,7 +396,7 @@ void LD6002BComponent::parse_byte_(uint8_t byte) { this->frame_type_ = read_u16_be(this->data_buf_ + 4); // The length is only trustworthy once the header checksum has been verified, so just // remember that the frame is oversized and let the HCK state act on it. - this->frame_oversize_ = this->data_len_ > DEFAULT_MAX_DATA_LEN; + this->frame_oversize_ = this->data_len_ > this->max_data_len_; this->parse_state_ = ParseState::HCK; } } @@ -267,16 +471,54 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ return; } +#ifdef ESPHOME_LOG_HAS_VERBOSE + const uint32_t active_control_command = + (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { + ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, + control_command_name(active_control_command), active_control_command); + } +#endif + switch (type) { case TYPE_REPORT_TARGET: this->handle_target_report_(data, len); break; + case TYPE_REPORT_POINT_CLOUD: + this->handle_point_cloud_(data, len); + break; + case TYPE_REPORT_DELAY: + this->handle_delay_report_(data, len); + break; + case TYPE_REPORT_Z_RANGE: + this->handle_z_range_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER: + this->handle_low_power_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER_SLEEP: + this->handle_low_power_sleep_report_(data, len); + break; + case TYPE_REPORT_WORK_MODE: + this->handle_work_mode_report_(data, len); + break; + case TYPE_QUERY_VERSION: + this->handle_version_report_(data, len); + break; default: break; } } void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) { + // The module stops streaming when it acts on the command, not when the command + // is queued, so trailing frames after an off must not repopulate what + // set_switch_state just cleared. + if (!this->target_display_enabled_) { + return; + } if (len < 4) return; @@ -339,6 +581,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) this->presence_binary_sensor_->publish_state(this->target_presence_any_); } #endif + this->update_work_mode_fallback_(); for (uint8_t i = 0; i < MAX_TARGETS; i++) { bool has_target = this->slot_occupied_[i]; @@ -373,26 +616,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) #endif } else { #ifdef USE_SENSOR - TargetSensors &target = this->targets_[i]; - if (this->last_target_presence_[i]) { - if (target.x != nullptr) { - target.x->publish_state(NAN); - } - if (target.y != nullptr) { - target.y->publish_state(NAN); - } - if (target.z != nullptr) { - target.z->publish_state(NAN); - } - if (target.dop_idx != nullptr) { - target.dop_idx->publish_state(NAN); - } - if (target.cluster_id != nullptr) { - target.cluster_id->publish_state(NAN); - } - // The slot is free: the next person's id is new even when it repeats this one. - this->last_cluster_id_valid_[i] = false; - } + this->clear_target_slot_(i); #endif } #ifdef USE_BINARY_SENSOR @@ -407,6 +631,150 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) } } +void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { + // Same window as the target stream: a frame already in flight must not put the + // count back after the switch cleared it. + if (!this->point_cloud_enabled_) { + return; + } + if (len < 4) + return; + +#ifdef USE_SENSOR + uint32_t point_num = read_u32_le(data); + if (this->point_count_sensor_ != nullptr) { + if (point_num != this->last_point_count_) { + this->point_count_sensor_->publish_state(point_num); + this->last_point_count_ = point_num; + } + } +#endif +} + +void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t delay = read_u32_le(data); + this->publish_number_clamped_(this->hold_delay_number_, delay); +#endif +} + +void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { + if (len < 8) + return; + float z_min = read_f32_le(data); + float z_max = read_f32_le(data + 4); + this->z_min_ = z_min; + this->z_max_ = z_max; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->z_min_number_, z_min); + this->publish_number_clamped_(this->z_max_number_, z_max); +#endif +} + +void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; + bool enabled = data[0] != 0; + this->low_power_enabled_ = enabled; + this->low_power_reported_ = true; +#ifdef USE_SWITCH + if (this->low_power_switch_ != nullptr) { + this->low_power_switch_->publish_state(enabled); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t sleep_ms = read_u32_le(data); + this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); +#endif +} + +void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_TEXT_SENSOR + const bool low_power = (data[0] == 0); + if (this->work_mode_text_sensor_ != nullptr) { + this->work_mode_reported_ = true; + this->publish_work_mode_(low_power); + } +#endif +} + +void LD6002BComponent::update_work_mode_fallback_() { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_) { + return; + } + if (!this->low_power_reported_) { + return; + } + // Presence is only meaningful while the stream that maintains it runs; with it + // off there is nothing to weigh and low power alone decides. + const bool presence = this->target_display_enabled_ && this->target_presence_any_; + this->publish_work_mode_(this->low_power_enabled_ && !presence); +#endif +} + +void LD6002BComponent::publish_work_mode_(bool low_power) { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr) { + return; + } + if (this->last_work_mode_valid_ && this->last_work_mode_low_power_ == low_power) { + return; + } + this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal"); + this->last_work_mode_valid_ = true; + this->last_work_mode_low_power_ = low_power; +#endif +} + +#ifdef USE_NUMBER +void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { + if (number == nullptr) + return; + const float min_value = number->traits.get_min_value(); + const float max_value = number->traits.get_max_value(); + // Outside the declared range the user cannot write the value back, so publish + // what they can reach and say what the module actually sent. + if (value < min_value || value > max_value) { + ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", number->get_name().c_str(), value, min_value, + max_value); + value = std::clamp(value, min_value, max_value); + } + number->publish_state(value); +} +#endif + +void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) + return; + uint8_t project = data[0]; + uint8_t major = data[1]; + uint8_t minor = data[2]; + uint8_t patch = data[3]; + char buf[32]; + if (project == 0) { + std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch); + } else { + std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch); + } + this->ota_version_text_sensor_->publish_state(buf); + this->save_version_pref_(buf); +#endif +} + void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { if (len > CMD_MAX_DATA_LEN) { ESP_LOGW(TAG, "Command data too large: %u", len); @@ -587,4 +955,173 @@ void LD6002BComponent::send_control_command_(uint32_t command) { this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } +void LD6002BComponent::send_z_range_() { + // One frame carries both bounds, so half a range cannot be written. + if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { + ESP_LOGW(TAG, "Z range not written, other bound unknown"); + return; + } + // Both bounds are known and crossed; the frame has no way to say that. + if (this->z_min_ > this->z_max_) { + ESP_LOGW(TAG, "Z range not written, min above max"); + return; + } + uint8_t data[8]; + write_f32_le(data, this->z_min_); + write_f32_le(data + 4, this->z_max_); + this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); +} + +void LD6002BComponent::set_number_value(NumberType type, float value) { + switch (type) { + case NumberType::HOLD_DELAY: { + uint32_t delay = static_cast(value); + uint8_t data[4]; + write_u32_le(data, delay); + this->queue_command_(TYPE_SET_HOLD_DELAY, data, sizeof(data)); + break; + } + case NumberType::Z_MIN: + this->z_min_ = value; + this->send_z_range_(); + break; + case NumberType::Z_MAX: + this->z_max_ = value; + this->send_z_range_(); + break; + case NumberType::LOW_POWER_SLEEP: { + uint32_t sleep_ms = static_cast(value); + uint8_t data[4]; + write_u32_le(data, sleep_ms); + this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); + break; + } + } +} + +void LD6002BComponent::init_version_pref_() { +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) { + return; + } + this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference(); + this->version_pref_initialized_ = true; + + VersionPref pref{}; + if (this->version_pref_.load(&pref) && pref.value[0] != '\0') { + pref.value[sizeof(pref.value) - 1] = '\0'; + this->ota_version_text_sensor_->publish_state(pref.value); + } +#endif +} + +void LD6002BComponent::save_version_pref_(const char *value) { +#ifdef USE_TEXT_SENSOR + if (!this->version_pref_initialized_) { + return; + } + VersionPref pref{}; + std::strncpy(pref.value, value, sizeof(pref.value) - 1); + pref.value[sizeof(pref.value) - 1] = '\0'; + this->version_pref_.save(&pref); +#endif +} + +#ifdef USE_SENSOR +void LD6002BComponent::clear_target_slot_(uint8_t index) { + if (!this->last_target_presence_[index]) { + return; + } + TargetSensors &target = this->targets_[index]; + if (target.x != nullptr) { + target.x->publish_state(NAN); + } + if (target.y != nullptr) { + target.y->publish_state(NAN); + } + if (target.z != nullptr) { + target.z->publish_state(NAN); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(NAN); + } + if (target.cluster_id != nullptr) { + target.cluster_id->publish_state(NAN); + } + // The slot is free: the next person's id is new even when it repeats this one. + this->last_cluster_id_valid_[index] = false; +} +#endif + +void LD6002BComponent::clear_target_state_() { + // Nothing corrects any of this until the stream comes back. The slot table goes + // with it: slots key on cluster ids, which only track a person while reports are + // arriving, and the room can empty and refill across the gap -- so the next + // report starts from an empty table and fills slots in wire order, rather than + // handing one back to whoever last held that id. + for (uint8_t i = 0; i < MAX_TARGETS; i++) { +#ifdef USE_SENSOR + this->clear_target_slot_(i); + this->last_target_presence_[i] = false; +#endif + if (this->slot_occupied_[i]) { + this->slot_occupied_[i] = false; +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + this->target_presence_[i]->publish_state(false); + } +#endif + } + } +#ifdef USE_SENSOR + if (this->last_target_count_ != 0xFFFFFFFF) { + if (this->target_count_sensor_ != nullptr) { + this->target_count_sensor_->publish_state(NAN); + } + this->last_target_count_ = 0xFFFFFFFF; + } +#endif + if (this->target_presence_any_) { + this->target_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(this->target_presence_any_); + } +#endif + this->update_work_mode_fallback_(); + } +} + +void LD6002BComponent::set_switch_state(SwitchType type, bool state) { + switch (type) { + case SwitchType::LOW_POWER: + this->low_power_enabled_ = state; + this->low_power_reported_ = true; + this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF); + this->update_work_mode_fallback_(); + break; + case SwitchType::POINT_CLOUD: + this->point_cloud_enabled_ = state; + this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); +#ifdef USE_SENSOR + // The count only moves while the stream runs, so the last one would stand as + // a live reading. The dedup sentinel is cleared with it: the same count is + // new again when the stream comes back. + if (!state && this->point_count_sensor_ != nullptr && this->last_point_count_ != 0xFFFFFFFF) { + this->point_count_sensor_->publish_state(NAN); + this->last_point_count_ = 0xFFFFFFFF; + } +#endif + break; + case SwitchType::TARGET_DISPLAY: + this->target_display_enabled_ = state; + this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF); + if (!state) { + // Every target entity is fed by the reports this just stopped. + this->clear_target_state_(); + } + break; + } +} + } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 8bbfb9f6e4..5630d2d1a8 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -11,16 +11,41 @@ #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/core/preferences.h" +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif #include +#include namespace esphome::ld6002b { static constexpr uint8_t MAX_TARGETS = 3; static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; +static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; // Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. static constexpr size_t CMD_MAX_DATA_LEN = 28; +enum class NumberType : uint8_t { + HOLD_DELAY, + Z_MIN, + Z_MAX, + LOW_POWER_SLEEP, +}; + +enum class SwitchType : uint8_t { + LOW_POWER, + POINT_CLOUD, + TARGET_DISPLAY, +}; + #ifdef USE_SENSOR struct TargetSensors { sensor::Sensor *x{nullptr}; @@ -32,6 +57,10 @@ struct TargetSensors { #endif +struct VersionPref { + char value[20]; +}; + class LD6002BComponent : public Component, public uart::UARTDevice { public: void setup() override; @@ -45,6 +74,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { #ifdef USE_SENSOR void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; } + void set_point_count_sensor(sensor::Sensor *sensor) { this->point_count_sensor_ = sensor; } void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { if (target >= MAX_TARGETS) @@ -82,6 +112,27 @@ class LD6002BComponent : public Component, public uart::UARTDevice { } #endif +#ifdef USE_TEXT_SENSOR + void set_work_mode_text_sensor(text_sensor::TextSensor *sensor) { this->work_mode_text_sensor_ = sensor; } + void set_ota_version_text_sensor(text_sensor::TextSensor *sensor) { this->ota_version_text_sensor_ = sensor; } +#endif + +#ifdef USE_NUMBER + void set_hold_delay_number(number::Number *number) { this->hold_delay_number_ = number; } + void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } + void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } + void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } +#endif + +#ifdef USE_SWITCH + void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; } + void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; } + void set_target_display_switch(switch_::Switch *sw) { this->target_display_switch_ = sw; } +#endif + + void set_number_value(NumberType type, float value); + void set_switch_state(SwitchType type, bool state); + protected: enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; @@ -95,6 +146,25 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void reset_parser_(); void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); void handle_target_report_(const uint8_t *data, uint16_t len); + void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_low_power_report_(const uint8_t *data, uint16_t len); + void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); + void handle_work_mode_report_(const uint8_t *data, uint16_t len); + void handle_version_report_(const uint8_t *data, uint16_t len); + void update_work_mode_fallback_(); + void publish_work_mode_(bool low_power); + // Drops every target-derived reading and the slot table they are indexed by. + void clear_target_state_(); +#ifdef USE_SENSOR + void clear_target_slot_(uint8_t index); +#endif +#ifdef USE_NUMBER + void publish_number_clamped_(number::Number *number, float value); +#endif + void init_version_pref_(); + void save_version_pref_(const char *value); void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); void process_command_queue_(); @@ -102,21 +172,41 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void send_control_command_(uint32_t command); + void send_z_range_(); static uint16_t read_u16_be(const uint8_t *data); static uint32_t read_u32_le(const uint8_t *data); static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); + static void write_f32_le(uint8_t *data, float value); #ifdef USE_SENSOR std::array targets_{}; sensor::Sensor *target_count_sensor_{nullptr}; + sensor::Sensor *point_count_sensor_{nullptr}; #endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; #endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; + text_sensor::TextSensor *ota_version_text_sensor_{nullptr}; + ESPPreferenceObject version_pref_{}; + bool version_pref_initialized_{false}; +#endif +#ifdef USE_NUMBER + number::Number *hold_delay_number_{nullptr}; + number::Number *z_min_number_{nullptr}; + number::Number *z_max_number_{nullptr}; + number::Number *low_power_sleep_number_{nullptr}; +#endif +#ifdef USE_SWITCH + switch_::Switch *low_power_switch_{nullptr}; + switch_::Switch *point_cloud_switch_{nullptr}; + switch_::Switch *target_display_switch_{nullptr}; +#endif GPIOPin *wakeup_pin_{nullptr}; uint32_t wakeup_pulse_ms_{50}; @@ -132,6 +222,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { uint8_t data_xor_{0}; uint32_t discard_remaining_{0}; bool frame_oversize_{false}; + size_t max_data_len_{0}; uint8_t *data_buf_{nullptr}; uint16_t next_frame_id_{0}; @@ -173,11 +264,24 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // Bumped whenever the active command changes, so a deferred send can tell it was retired. uint8_t send_generation_{0}; + float z_min_{NAN}; + float z_max_{NAN}; + // Which person owns each target_N slot, so a slot survives the module re-sorting its array. std::array slot_cluster_{}; std::array slot_occupied_{}; bool target_presence_any_{false}; + // What the switches and setup asked the module for, which is not the same as + // what it is doing yet: a stream keeps sending until it acts on the command. + // The report handlers read these and drop anything a stopped stream still emits. + bool target_display_enabled_{false}; + bool point_cloud_enabled_{false}; + bool work_mode_reported_{false}; + bool low_power_enabled_{false}; + bool low_power_reported_{false}; + bool last_work_mode_valid_{false}; + bool last_work_mode_low_power_{false}; #ifdef USE_SENSOR std::array last_target_presence_{}; // one-shot NAN clear for target sensors @@ -185,6 +289,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { std::array last_cluster_id_{}; std::array last_cluster_id_valid_{}; uint32_t last_target_count_{0xFFFFFFFF}; + uint32_t last_point_count_{0xFFFFFFFF}; #endif }; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py new file mode 100644 index 0000000000..10e9e89dc8 --- /dev/null +++ b/esphome/components/ld6002b/number/__init__.py @@ -0,0 +1,82 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + UNIT_METER, + UNIT_MILLISECOND, + UNIT_SECOND, +) + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_HOLD_DELAY, + CONF_LD6002B_ID, + CONF_LOW_POWER_SLEEP_TIME, + CONF_Z_MAX, + CONF_Z_MIN, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BNumber = ld6002b_ns.class_("LD6002BNumber", number.Number) +NumberType = ld6002b_ns.enum("NumberType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_HOLD_DELAY): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_SECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, number_type, setter, min_value, max_value, step in ( + (CONF_HOLD_DELAY, NumberType.HOLD_DELAY, "set_hold_delay_number", 0, 65535, 1), + (CONF_Z_MIN, NumberType.Z_MIN, "set_z_min_number", -10, 10, 0.1), + (CONF_Z_MAX, NumberType.Z_MAX, "set_z_max_number", -10, 10, 0.1), + # 0x0205 carries a uint32 of milliseconds; the vendor documents 500 ms as + # the default and no upper bound, so the range ends at a minute rather + # than at a default the module is free to be sleeping past. + ( + CONF_LOW_POWER_SLEEP_TIME, + NumberType.LOW_POWER_SLEEP, + "set_low_power_sleep_number", + 0, + 60000, + 100, + ), + ): + if conf := config.get(key): + n = await number.new_number( + conf, number_type, min_value=min_value, max_value=max_value, step=step + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/number/ld6002b_number.cpp b/esphome/components/ld6002b/number/ld6002b_number.cpp new file mode 100644 index 0000000000..b0b1b6f72b --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_number.h" + +namespace esphome::ld6002b { + +void LD6002BNumber::control(float value) { + this->publish_state(value); + this->parent_->set_number_value(this->type_, value); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/ld6002b_number.h b/esphome/components/ld6002b/number/ld6002b_number.h new file mode 100644 index 0000000000..3101b4d3cd --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BNumber : public number::Number, public Parented { + public: + explicit LD6002BNumber(NumberType type) : type_(type) {} + + protected: + void control(float value) override; + + NumberType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 3aa9b0f98a..ff88d343b9 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -15,6 +15,7 @@ from .const import ( CONF_CLUSTER_ID, CONF_DOPPLER_INDEX, CONF_LD6002B_ID, + CONF_POINT_COUNT, CONF_Z, MAX_TARGETS, ) @@ -75,6 +76,10 @@ CONFIG_SCHEMA = cv.Schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) @@ -86,6 +91,10 @@ async def to_code(config): sens = await sensor.new_sensor(target_count_config) cg.add(hub.set_target_count_sensor(sens)) + if point_count_config := config.get(CONF_POINT_COUNT): + sens = await sensor.new_sensor(point_count_config) + cg.add(hub.set_point_count_sensor(sens)) + for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): if x_config := target_config.get(CONF_X): diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py new file mode 100644 index 0000000000..d27baa87fe --- /dev/null +++ b/esphome/components/ld6002b/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_LD6002B_ID, + CONF_LOW_POWER, + CONF_POINT_CLOUD, + CONF_TARGET_DISPLAY, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BSwitch = ld6002b_ns.class_("LD6002BSwitch", switch.Switch) +SwitchType = ld6002b_ns.enum("SwitchType", is_class=True) + +# None of these three carry an inversion. They name what the module is doing, not +# how something is wired to it, so an inverted one would only report the opposite +# of the truth -- and the boot restore, which applies a state nothing reports back, +# is where that would be hardest to spot. +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_LOW_POWER): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_POINT_CLOUD): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_ON", + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, switch_type, setter in ( + (CONF_LOW_POWER, SwitchType.LOW_POWER, "set_low_power_switch"), + (CONF_POINT_CLOUD, SwitchType.POINT_CLOUD, "set_point_cloud_switch"), + (CONF_TARGET_DISPLAY, SwitchType.TARGET_DISPLAY, "set_target_display_switch"), + ): + if conf := config.get(key): + s = await switch.new_switch(conf, switch_type) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.cpp b/esphome/components/ld6002b/switch/ld6002b_switch.cpp new file mode 100644 index 0000000000..7542f7b1ff --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_switch.h" + +namespace esphome::ld6002b { + +void LD6002BSwitch::write_state(bool state) { + this->parent_->set_switch_state(this->type_, state); + this->publish_state(state); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.h b/esphome/components/ld6002b/switch/ld6002b_switch.h new file mode 100644 index 0000000000..44773f802f --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSwitch : public switch_::Switch, public Parented { + public: + explicit LD6002BSwitch(SwitchType type) : type_(type) {} + + protected: + void write_state(bool state) override; + + SwitchType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py new file mode 100644 index 0000000000..a18d387437 --- /dev/null +++ b/esphome/components/ld6002b/text_sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC + +from . import LD6002BComponent +from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE + +DEPENDENCIES = ["ld6002b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + if work_mode_config := config.get(CONF_WORK_MODE): + sens = await text_sensor.new_text_sensor(work_mode_config) + cg.add(hub.set_work_mode_text_sensor(sens)) + if ota_config := config.get(CONF_OTA_VERSION): + sens = await text_sensor.new_text_sensor(ota_config) + cg.add(hub.set_ota_version_text_sensor(sens)) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index 15ab06c394..f8a9e95340 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -7,6 +7,8 @@ sensor: ld6002b_id: ld6002b_radar target_count: name: Target Count + point_count: + name: Point Count target_1: x: name: Target-1 X @@ -48,3 +50,33 @@ binary_sensor: name: Presence target_1: name: Target-1 Presence + +text_sensor: + - platform: ld6002b + ld6002b_id: ld6002b_radar + work_mode: + name: Work Mode + ota_version: + name: OTA Version + +number: + - platform: ld6002b + ld6002b_id: ld6002b_radar + hold_delay: + name: Hold Delay + z_min: + name: Z Min + z_max: + name: Z Max + low_power_sleep_time: + name: Low Power Sleep + +switch: + - platform: ld6002b + ld6002b_id: ld6002b_radar + low_power: + name: Low Power + point_cloud: + name: Point Cloud + target_display: + name: Target Display From 5f483b11b6c9b3b222d6e0bd25239aa95e23af69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 19:19:08 +0300 Subject: [PATCH 006/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 1: ble_presence, ble_rssi, ble_scanner) (#17716) --- .../components/ble_presence/binary_sensor.py | 30 ++++++++----------- .../ble_presence/ble_presence_device.cpp | 4 --- .../ble_presence/ble_presence_device.h | 26 ++++++++-------- .../components/ble_rssi/ble_rssi_sensor.cpp | 4 --- esphome/components/ble_rssi/ble_rssi_sensor.h | 26 ++++++++-------- esphome/components/ble_rssi/sensor.py | 30 ++++++++----------- .../components/ble_scanner/ble_scanner.cpp | 4 --- esphome/components/ble_scanner/ble_scanner.h | 16 +++++----- esphome/components/ble_scanner/text_sensor.py | 11 +++---- tests/components/ble_presence/common-ln.yaml | 4 +++ tests/components/ble_presence/common.yaml | 3 ++ .../ble_presence/test.ln882x-ard.yaml | 3 ++ .../ble_presence/validate.bk72xx-ard.yaml | 14 +++++++++ tests/components/ble_rssi/common-ln.yaml | 5 ++++ tests/components/ble_rssi/common.yaml | 7 ++++- .../components/ble_rssi/test.ln882x-ard.yaml | 3 ++ .../validate-legacy-key.esp32-idf.yaml | 11 +++++++ .../ble_rssi/validate.bk72xx-ard.yaml | 14 +++++++++ tests/components/ble_scanner/common-ln.yaml | 3 ++ tests/components/ble_scanner/common.yaml | 3 ++ .../ble_scanner/test.ln882x-ard.yaml | 3 ++ .../ble_scanner/validate.bk72xx-ard.yaml | 13 ++++++++ 22 files changed, 150 insertions(+), 87 deletions(-) create mode 100644 tests/components/ble_presence/common-ln.yaml create mode 100644 tests/components/ble_presence/test.ln882x-ard.yaml create mode 100644 tests/components/ble_presence/validate.bk72xx-ard.yaml create mode 100644 tests/components/ble_rssi/common-ln.yaml create mode 100644 tests/components/ble_rssi/test.ln882x-ard.yaml create mode 100644 tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml create mode 100644 tests/components/ble_rssi/validate.bk72xx-ard.yaml create mode 100644 tests/components/ble_scanner/common-ln.yaml create mode 100644 tests/components/ble_scanner/test.ln882x-ard.yaml create mode 100644 tests/components/ble_scanner/validate.bk72xx-ard.yaml diff --git a/esphome/components/ble_presence/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index 3a0f1ade98..a7713d9a4b 100644 --- a/esphome/components/ble_presence/binary_sensor.py +++ b/esphome/components/ble_presence/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -13,14 +13,14 @@ from esphome.const import ( CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_presence_ns = cg.esphome_ns.namespace("ble_presence") BLEPresenceDevice = ble_presence_ns.class_( "BLEPresenceDevice", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) @@ -33,23 +33,24 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_presence"), binary_sensor.binary_sensor_schema(BLEPresenceDevice) .extend( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_TIMEOUT, default="5min"): cv.positive_time_period, cv.Optional(CONF_MIN_RSSI): cv.All( cv.decibel, cv.int_range(min=-100, max=-30) ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds)) if min_rssi := config.get(CONF_MIN_RSSI): @@ -70,20 +71,15 @@ async def to_code(config): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index 4a70648ac5..bc169623ce 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -1,8 +1,6 @@ #include "ble_presence_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_presence { static const char *const TAG = "ble_presence"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_presence"; void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index e17e26ff1c..4e49cc32a3 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -1,15 +1,17 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_presence { class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, + public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { @@ -22,19 +24,19 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -49,7 +51,7 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, this->minimum_rssi_ = rssi; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) { return false; } @@ -119,9 +121,9 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_{0}; uint16_t ibeacon_minor_{0}; @@ -137,5 +139,3 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, }; } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp index f678865f47..7c7c7b2148 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -1,8 +1,6 @@ #include "ble_rssi_sensor.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_rssi { static const char *const TAG = "ble_rssi"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_rssi"; void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index 8e804ab8e7..a30b94b8b7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -1,14 +1,16 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_rssi { -class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; @@ -20,19 +22,19 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -47,7 +49,7 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP this->publish_state(NAN); this->found_ = false; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { switch (this->match_by_) { case MATCH_BY_MAC_ADDRESS: if (device.address_uint64() == this->address_) { @@ -109,9 +111,9 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_; uint16_t ibeacon_minor_; @@ -120,5 +122,3 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP }; } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index c4e767aa21..43e5813ea2 100644 --- a/esphome/components/ble_rssi/sensor.py +++ b/esphome/components/ble_rssi/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -14,11 +14,11 @@ from esphome.const import ( CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi") BLERSSISensor = ble_rssi_ns.class_( - "BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener + "BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener ) @@ -31,6 +31,7 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_rssi"), sensor.sensor_schema( BLERSSISensor, unit_of_measurement=UNIT_DECIBEL_MILLIWATT, @@ -42,14 +43,14 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -60,26 +61,21 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) if mac_address := config.get(CONF_MAC_ADDRESS): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index d85894edc8..3d7a301793 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -1,8 +1,6 @@ #include "ble_scanner.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_scanner { static const char *const TAG = "ble_scanner"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_scanner"; void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index b4e4488646..0efc42682b 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -7,18 +7,18 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/text_sensor/text_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_scanner { -class BLEScanner final : public text_sensor::TextSensor, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEScanner final : public text_sensor::TextSensor, public ble_device_base::ESPBTDeviceListener, public Component { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this // sensor has always published. @@ -35,5 +35,3 @@ class BLEScanner final : public text_sensor::TextSensor, }; } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/text_sensor.py b/esphome/components/ble_scanner/text_sensor.py index 96d71a0399..0c08e1f734 100644 --- a/esphome/components/ble_scanner/text_sensor.py +++ b/esphome/components/ble_scanner/text_sensor.py @@ -1,25 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, text_sensor +from esphome.components import ble_device_base, text_sensor import esphome.config_validation as cv -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner") BLEScanner = ble_scanner_ns.class_( "BLEScanner", text_sensor.TextSensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_scanner"), text_sensor.text_sensor_schema(BLEScanner) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/tests/components/ble_presence/common-ln.yaml b/tests/components/ble_presence/common-ln.yaml new file mode 100644 index 0000000000..2cc5075efe --- /dev/null +++ b/tests/components/ble_presence/common-ln.yaml @@ -0,0 +1,4 @@ +binary_sensor: + - platform: ble_presence + mac_address: 11:22:33:44:55:66 + name: BLE Test Presence diff --git a/tests/components/ble_presence/common.yaml b/tests/components/ble_presence/common.yaml index 2ba6aa0754..bd2bb9fecc 100644 --- a/tests/components/ble_presence/common.yaml +++ b/tests/components/ble_presence/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_presence + ble_hub_id: ble_tracker_hub mac_address: AC:37:43:77:5F:4C name: ESP32 BLE Tracker Google Home Mini - platform: ble_presence diff --git a/tests/components/ble_presence/test.ln882x-ard.yaml b/tests/components/ble_presence/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6a359c772c --- /dev/null +++ b/tests/components/ble_presence/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_presence: !include common-ln.yaml diff --git a/tests/components/ble_presence/validate.bk72xx-ard.yaml b/tests/components/ble_presence/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..92b39f1255 --- /dev/null +++ b/tests/components/ble_presence/validate.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +binary_sensor: + - platform: ble_presence + ble_hub_id: ble_hub + mac_address: AC:37:43:77:5F:4C + name: BK BLE Presence + - platform: ble_presence + irk: 1234567890abcdef1234567890abcdef + name: BK BLE Presence IRK diff --git a/tests/components/ble_rssi/common-ln.yaml b/tests/components/ble_rssi/common-ln.yaml new file mode 100644 index 0000000000..f0ccc2df06 --- /dev/null +++ b/tests/components/ble_rssi/common-ln.yaml @@ -0,0 +1,5 @@ +sensor: + - platform: ble_rssi + # irk: is the only thing that emits USE_BLE_DEVICE_IRK off ESP32 + irk: 1234567890abcdef1234567890abcdef + name: BLE Test RSSI diff --git a/tests/components/ble_rssi/common.yaml b/tests/components/ble_rssi/common.yaml index 43bed1d0e7..bbedf17c37 100644 --- a/tests/components/ble_rssi/common.yaml +++ b/tests/components/ble_rssi/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_rssi + ble_hub_id: ble_tracker_hub mac_address: AC:37:43:77:5F:4C name: BLE Google Home Mini RSSI value - platform: ble_rssi @@ -14,7 +17,9 @@ sensor: service_uuid: 11223344-5566-7788-99aa-bbccddeeff00 name: BLE Test Service 128 - platform: ble_rssi - service_uuid: 11223344-5566-7788-99aa-bbccddeeff00 + ibeacon_uuid: 11223344-5566-7788-99aa-bbccddeeff00 + ibeacon_major: 100 + ibeacon_minor: 1 name: BLE Test iBeacon UUID - platform: ble_rssi irk: 1234567890abcdef1234567890abcdef diff --git a/tests/components/ble_rssi/test.ln882x-ard.yaml b/tests/components/ble_rssi/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3554484ca3 --- /dev/null +++ b/tests/components/ble_rssi/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_rssi: !include common-ln.yaml diff --git a/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml b/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml new file mode 100644 index 0000000000..926701117e --- /dev/null +++ b/tests/components/ble_rssi/validate-legacy-key.esp32-idf.yaml @@ -0,0 +1,11 @@ +# Config-only: pins the esp32_ble_id: -> ble_hub_id: deprecation alias — the +# legacy key must keep validating (with a rename warning) until its removal +# release (2027.2.0). +esp32_ble_tracker: + id: legacy_tracker + +sensor: + - platform: ble_rssi + esp32_ble_id: legacy_tracker + mac_address: AC:37:43:77:5F:4C + name: Legacy Key RSSI diff --git a/tests/components/ble_rssi/validate.bk72xx-ard.yaml b/tests/components/ble_rssi/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..8fb6bdd201 --- /dev/null +++ b/tests/components/ble_rssi/validate.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: ble_rssi + ble_hub_id: ble_hub + mac_address: AC:37:43:77:5F:4C + name: BK BLE RSSI + - platform: ble_rssi + irk: 1234567890abcdef1234567890abcdef + name: BK BLE RSSI IRK diff --git a/tests/components/ble_scanner/common-ln.yaml b/tests/components/ble_scanner/common-ln.yaml new file mode 100644 index 0000000000..6c732031d8 --- /dev/null +++ b/tests/components/ble_scanner/common-ln.yaml @@ -0,0 +1,3 @@ +text_sensor: + - platform: ble_scanner + name: BLE Test Scanner diff --git a/tests/components/ble_scanner/common.yaml b/tests/components/ble_scanner/common.yaml index 935a5a5a19..5c8d09892f 100644 --- a/tests/components/ble_scanner/common.yaml +++ b/tests/components/ble_scanner/common.yaml @@ -1,5 +1,8 @@ esp32_ble_tracker: + id: ble_tracker_hub text_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ble_scanner + ble_hub_id: ble_tracker_hub name: Scanner diff --git a/tests/components/ble_scanner/test.ln882x-ard.yaml b/tests/components/ble_scanner/test.ln882x-ard.yaml new file mode 100644 index 0000000000..26dbe4476d --- /dev/null +++ b/tests/components/ble_scanner/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ble_scanner: !include common-ln.yaml diff --git a/tests/components/ble_scanner/validate.bk72xx-ard.yaml b/tests/components/ble_scanner/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..cb025d74b7 --- /dev/null +++ b/tests/components/ble_scanner/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +text_sensor: + - platform: ble_scanner + ble_hub_id: ble_hub + name: BK Scanner + # No ble_hub_id: exercises the generated binding _require_hub guards. + - platform: ble_scanner + name: BK Scanner Implicit From c5e165d0620d8fc21f7abf99a2318ae7a08148d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 11:29:19 -0500 Subject: [PATCH 007/597] [bluetooth_proxy] Make the GATT dispatch platform neutral (#18130) --- .../ble_device_base/ble_gatt_client.h | 3 +- .../bluetooth_connection/__init__.py | 11 + .../bluetooth_connection.cpp | 42 ++ .../bluetooth_connection.h | 111 ++++- .../bluetooth_connection_esp32.cpp | 169 ++----- .../bluetooth_connection_esp32.h | 11 +- .../bluetooth_connection_hub.cpp | 415 ++++++++++++++++++ .../bluetooth_connection_hub.h | 129 ++++++ .../components/bluetooth_proxy/__init__.py | 16 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 176 ++++++-- .../bluetooth_proxy/bluetooth_proxy.h | 116 +++-- .../bluetooth_proxy/test_platform_gates.py | 20 +- .../bluetooth_connection/test_gatt_uuid.cpp | 49 +++ 13 files changed, 1031 insertions(+), 237 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_hub.h create mode 100644 tests/components/bluetooth_connection/test_gatt_uuid.cpp diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index ef28f7672a..1bcfcf99dc 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -128,7 +128,8 @@ class BLEGattConnection { /// Backend-owned service table (see GattServiceTable lifetime). virtual GattServiceTable get_service_table() = 0; - /// Free the transient service table storage. Call after streaming. + /// Free the transient service table storage. Call after streaming; + /// idempotent (a call with no table held is a no-op). virtual void release_services() = 0; protected: diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1e85c4b8f9..d4d0a3c3af 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -24,6 +24,10 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") +# The hub-platform wrapper codegen class (drives a ble_device_base +# BLEGattConnection backend; see bluetooth_connection_hub.h). +HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") + @functools.cache def esp32_connection_class() -> cg.MockObjClass: @@ -42,5 +46,12 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, } ) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp new file mode 100644 index 0000000000..57833edbd2 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -0,0 +1,42 @@ +#include "bluetooth_connection.h" + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str) { + // Calculate the actual size of just this service (+1 for the field tag) + size_t service_size = resp.services.back().calculate_size() + 1; + + if (current_size + service_size > MAX_PACKET_SIZE) { + if (resp.services.size() > 1) { + // We would go over -- pop the last service and retry it in the next batch + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch", + connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size, + (unsigned) MAX_PACKET_SIZE); + // Don't advance send_service -- the popped service goes into the next batch + } else { + // This single service is too large, but we have to send it anyway; + // advance so we don't get stuck + ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str, + send_service, (unsigned) service_size); + send_service++; + } + return BatchClose::SEND; + } + + current_size += service_size; + send_service++; + return BatchClose::CONTINUE; +} + +} // namespace esphome::bluetooth_connection + +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index f63fb93492..712251b157 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -1,16 +1,32 @@ -// Shared types for the per-platform GATT connection backends and the -// Bluetooth proxy that drives them. +// Shared types and helpers for the per-platform GATT connection backends and +// the Bluetooth proxy that drives them. #pragma once #include "esphome/core/defines.h" #include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include +#include +#include #ifdef USE_ESP32 #include #endif +// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub +// platform with the neutral GATT client compiled in. Single-sourced here so +// the proxy and this component cannot drift. +#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +#define BLUETOOTH_CONNECTION_HAS_GATT +#endif + +namespace esphome::api { +class BluetoothGATTGetServicesResponse; +} // namespace esphome::api + namespace esphome::bluetooth_connection { // Connection-owned error type for the API error fields, which are plain @@ -30,8 +46,99 @@ static constexpr conn_err_t CONN_OK = 0; // GATT contract so backend and wrapper cannot drift. static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; +// What the platform's connection backend supports beyond GATT operations; +// the proxy derives its feature flags and legacy version from these. +#ifdef USE_ESP32 +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#else +static constexpr bool SUPPORTS_PAIRING = false; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#endif + +// Address-scoped (not connection-scoped) maintenance requests. +#ifdef USE_ESP32 +conn_err_t unpair_device(uint64_t address); +conn_err_t clear_gatt_cache(uint64_t address); +#else +inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } +#endif + // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +// ---- Service-streaming size budget, shared by every platform's streamer ---- + +// Conservative MTU limit for API messages (accounts for WPA3 overhead) +static constexpr size_t MAX_PACKET_SIZE = 1360; + +// 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_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +/// Estimate the wire size of a service (service overhead + its characteristics, +/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be +/// safe) before fetching/packing the full data. +inline 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; + return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count; +} + +// ---- UUID wire packing, shared by every platform's streamer ---- + +// This function is allocation-free and directly packs UUIDs into the output +// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID +// stores its 128-bit form little-endian (same as Bluedroid). +inline void fill_128bit_uuid_array(std::array &out, const ble_device_base::ESPBTUUID &uuid) { + using ble_device_base::ESPBTUUID; + if (uuid.type() == ESPBTUUID::Type::UUID128) { + const uint8_t *u = uuid.uuid128(); + // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) + out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | + ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); + out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | + ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + return; + } + // 16/32-bit UUID inserted into the Bluetooth base UUID: + // 00000000-0000-1000-8000-00805F9B34FB + uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32(); + out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11 + out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7 +} + +/// Fill the UUID in the appropriate wire format based on client support and +/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form +/// otherwise). +inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, + const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) { + using ble_device_base::ESPBTUUID; + if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) { + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.type() == ESPBTUUID::Type::UUID16) { + short_uuid = uuid.uuid16(); + } else { + short_uuid = uuid.uuid32(); + } +} + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +/// Result of close_service_batch: keep filling the batch or send it now. +/// An oversized service is packed alone; a failed (backpressured) send is +/// retried from the batch start, so no service is silently skipped. +enum class BatchClose : uint8_t { CONTINUE, SEND }; + +/// Close out the service just packed into resp (account its actual wire size, +/// advance the cursor) and decide whether the batch must be sent now. Shared +/// tail of both platform streamers so the budget logic and its log lines +/// cannot drift. +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str); +#endif // BLUETOOTH_CONNECTION_HAS_GATT + } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 5274637b66..7c62d3766c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -12,81 +12,20 @@ namespace esphome::bluetooth_connection { namespace espbt = esphome::esp32_ble_tracker; +using ble_device_base::ESPBTUUID; + static const char *const TAG = "bluetooth_connection"; -// This function is allocation-free and directly packs UUIDs into the output array -// using precalculated constants for the Bluetooth base UUID -static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB - // out[0] = bytes 8-15 (big-endian) - // - For 128-bit UUIDs: use bytes 8-15 as-is - // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 - out[0] = uuid_source.len == ESP_UUID_LEN_128 - ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) - : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) - << 32) | - 0x00001000ULL); // Base UUID bytes 8-11 - // out[1] = bytes 0-7 (big-endian) - // - For 128-bit UUIDs: use bytes 0-7 as-is - // - For 16/32-bit UUIDs: use precalculated base UUID constant - out[1] = uuid_source.len == ESP_UUID_LEN_128 - ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) - : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bd_addr; + ble_device_base::uint64_to_mac_msb_first(address, bd_addr); + return esp_ble_remove_bond_device(bd_addr); } -// Helper to fill UUID in the appropriate format based on client support and UUID type -static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, - bool use_efficient_uuids) { - if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(uuid_128, uuid); - } else if (uuid.len == ESP_UUID_LEN_16) { - short_uuid = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - short_uuid = uuid.uuid.uuid32; - } -} - -// 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); +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bd_addr; + ble_device_base::uint64_to_mac_msb_first(address, bd_addr); + return esp_ble_gattc_cache_clean(bd_addr); } void BluetoothConnection::dump_config() { @@ -94,28 +33,9 @@ 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; - for (auto &slot : allocated) { - if (slot == find_value) { - slot = set_value; - return; - } - } -} - 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); - } - + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); // Call parent implementation to actually set the address BLEClientBase::set_address(address); } @@ -157,20 +77,7 @@ void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { this->reset_connection_(reason); } -void BluetoothConnection::reset_connection_(esp_err_t reason) { - // Send disconnection notification - this->proxy_->send_device_connection(this->address_, false, 0, reason); - - // Important: If we were in the middle of sending services, we do NOT send - // send_gatt_services_done() here. This ensures the client knows that - // the service discovery was interrupted and can retry. The client - // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) - // to detect incomplete service discovery rather than relying on us to - // tell them about a partial list. - this->set_address(0); - this->send_service_ = INIT_SENDING_SERVICES; - this->proxy_->send_connections_free(); -} +void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } void BluetoothConnection::send_service_for_discovery_() { if (this->send_service_ >= this->service_count_) { @@ -188,18 +95,16 @@ void BluetoothConnection::send_service_for_discovery_() { } // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->supports_efficient_uuids_(); + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; // Dynamic batching based on actual size - // Conservative MTU limit for API messages (accounts for WPA3 overhead) - static constexpr size_t MAX_PACKET_SIZE = 1360; - // Keep running total of actual message size size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; @@ -238,7 +143,8 @@ void BluetoothConnection::send_service_for_discovery_() { 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); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid), + use_efficient_uuids); service_resp.handle = service_result.start_handle; @@ -268,7 +174,8 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid), + use_efficient_uuids); characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; char_offset++; @@ -309,44 +216,26 @@ void BluetoothConnection::send_service_for_discovery_() { 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); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, ESPBTUUID::from_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 - size_t service_size = service_resp.calculate_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(), 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(), this->send_service_, service_size); - // Increment so we don't get stuck - this->send_service_++; - } - // Send what we have + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) != + BatchClose::CONTINUE) { 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 dynamically batched services - api_conn->send_message(resp); + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped. + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->send_service_ = batch_start; + } } void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index 65e2d0777e..531ff311a7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -34,6 +34,15 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); } + bool has_gatt_services() const { return this->service_count_ != 0; } + + /// Start connecting: record the API address type and hand the client to the + /// tracker's promote loop (it pauses the scan and opens the connection). + void initiate_connection(uint8_t address_type) { + this->set_remote_addr_type(static_cast(address_type)); + this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); + } + void set_address(uint64_t address) override; protected: @@ -41,10 +50,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { void on_disconnect_complete(esp_err_t reason) override; - 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); void log_connection_error_(const char *operation, esp_gatt_status_t status); void log_connection_warning_(const char *operation, esp_err_t err); void log_gatt_not_connected_(const char *action, const char *type); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp new file mode 100644 index 0000000000..338bf671a8 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -0,0 +1,415 @@ +// Hub-platform connection wrapper (USE_RP2 hub builds today). +#include "bluetooth_connection_hub.h" + +#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +void BluetoothConnection::set_address(uint64_t address) { + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); + this->address_ = address; + if (address == 0) { + this->address_str_[0] = '\0'; + return; + } + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + format_mac_addr_upper(mac, this->address_str_); +} + +void BluetoothConnection::start_connect_() { + // No connect timeout here (esp32 parity): the client's own timeout or + // the api-gone sweep drives disconnect(). + this->state_ = ClientState::CONNECTING; + int err = this->backend_->connect(this->address_, this->remote_addr_type_); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + } +} + +void BluetoothConnection::disconnect() { + // Idempotent like the esp32 class: the proxy's teardown loop calls this + // every 100 ms while the API subscriber is gone, and a repeat call must not + // reach the backend (whose busy error would free the slot mid-teardown). + if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { + return; + } + int err = this->backend_->disconnect(); + if (err == GATT_NOT_CONNECTED) { + // Backend already idle: free the slot so the client is not stuck. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_); + this->reset_connection_(err); + return; + } + if (err != 0) { + // Transient refusal: stay DISCONNECTING and let the safety timeout + // arbitrate rather than freeing a slot whose teardown is unresolved. + // Latch the refusal unless a GATT cause is already recorded (first wins). + ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err); + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + this->state_ = ClientState::DISCONNECTING; + this->disconnecting_started_ = millis(); +} + +void BluetoothConnection::check_disconnect_timeout_() { + // Safety net mirroring the esp32 base class: if the backend's disconnect + // completion is lost, force the slot free instead of leaking it. + static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; + if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); + this->reset_connection_(GATT_NOT_CONNECTED); + } +} + +void BluetoothConnection::reset_connection_(conn_err_t reason) { + if (this->pending_error_ != 0) { + reason = this->pending_error_; + this->pending_error_ = 0; + } + this->state_ = ClientState::IDLE; + this->services_discovered_ = false; + this->backend_->release_services(); + this->proxy_->reset_connection_slot_(this, reason); +} + +// ---- GattClientEventListener ---- + +void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { + if (connected && this->address_ == 0) { + // Late completion for a slot that was already freed: nothing to report, + // and the api-gone sweep or a new reservation owns the slot now. + int err = this->backend_->disconnect(); + if (err != 0 && err != GATT_NOT_CONNECTED) { + // Log only: re-arming a freed slot could clobber a new reservation. + ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err); + } + return; + } + if (connected && this->state_ == ClientState::DISCONNECTING) { + // The link came up after a disconnect request won the race; finish the + // teardown instead of reporting a connection the client no longer wants. + int err = this->backend_->disconnect(); + // Fresh teardown attempt: give it the full safety window. + this->disconnecting_started_ = millis(); + if (err == GATT_NOT_CONNECTED) { + // Nothing left to tear down after all. + this->reset_connection_(err); + } else if (err != 0) { + // Transient refusal while the link is up: keep DISCONNECTING and let + // the safety timeout arbitrate (same policy as disconnect()). + ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err); + } + return; + } + if (connected) { + this->mtu_ = mtu; + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + // The API client has the services cached; never discover them. + this->state_ = ClientState::ESTABLISHED; + this->proxy_->send_device_connection(this->address_, true, mtu); + this->proxy_->send_connections_free(); + return; + } + // V3_WITHOUT_CACHE: discover services first — the connected response is + // sent when discovery completes, mirroring the esp32 flow (MTU + services + // before the response). + this->state_ = ClientState::CONNECTED; + int err = this->backend_->discover_services(); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); + // Latch the real cause for the disconnect report. + this->pending_error_ = err; + this->disconnect(); + } + return; + } + // Disconnected, connect failed, or teardown complete + if (this->address_ == 0) { + return; // Slot already freed + } + ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, + error); + this->reset_connection_(error); +} + +void BluetoothConnection::on_service_discovery_done(int error) { + if (error != 0) { + ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); + // Carry the GATT error into the disconnection report so the client sees + // the real cause instead of a generic HCI reason. + this->pending_error_ = error; + this->disconnect(); + return; + } + ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_, + this->mtu_); + this->state_ = ClientState::ESTABLISHED; + this->services_discovered_ = true; + this->proxy_->send_device_connection(this->address_, true, this->mtu_); + this->proxy_->send_connections_free(); +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, + operation, handle, status); +} + +void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { + // Late completion for a freed slot; nothing to report. + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("reading char/descriptor", handle, error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTReadResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_write_result(uint16_t handle, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("writing char/descriptor", handle, error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTWriteResponse resp; + resp.address = this->address_; + resp.handle = handle; + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, + error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyResponse resp; + resp.address = this->address_; + resp.handle = handle; + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->address_ == 0) + return; + ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle); + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyDataResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); + } +} + +// ---- GATT operations ---- + +conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const { + if (this->connected()) { + return CONN_OK; + } + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action, + type); + return GATT_NOT_CONNECTED; +} + +conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_characteristic(handle); +} + +conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { + if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_characteristic(handle, data, static_cast(length), response); +} + +conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_descriptor(handle); +} + +// The neutral backend contract performs descriptor writes acknowledged, so +// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). +conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, + bool /*response*/) { + if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_descriptor(handle, data, static_cast(length)); +} + +conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, + enable ? "Registering for" : "Unregistering for", handle); + return this->backend_->notify_characteristic(handle, enable); +} + +conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK) + return err; + return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout); +} + +// ---- Service streaming ---- + +void BluetoothConnection::send_service_for_discovery_() { + auto table = this->backend_->get_service_table(); + if (this->send_service_ >= table.service_count) { + this->send_service_ = DONE_SENDING_SERVICES; + this->proxy_->send_gatt_services_done(this->address_); + this->backend_->release_services(); + return; + } + + // The subscriber vanished mid-stream: park the cursor at done WITHOUT + // sending services-done (esp32 parity — a resubscribing client gets + // silence and its 30 s timeout, never an authoritative partial list) and + // free the table; the api-gone sweep tears the connection down anyway. + auto *api_conn = this->proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, + this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + this->backend_->release_services(); + return; + } + + // Check if client supports efficient UUIDs + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); + + // Prepare response + api::BluetoothGATTGetServicesResponse resp; + resp.address = this->address_; + + // Dynamic batching based on actual size, same contract as the esp32 streamer + size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; + + while (this->send_service_ < table.service_count) { + const auto &service = table.services[this->send_service_]; + + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(service.characteristic_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service.uuid, use_efficient_uuids); + service_resp.handle = service.start_handle; + + // Bounds-check the backend's index ranges against the table totals rather + // than trusting its discovery bookkeeping blindly. A miscounted non-empty + // range must not stream a truncated database as authoritative (V3 clients + // cache it permanently): abort and tear the connection down; the client + // times out and retries. Empty ranges are tolerated regardless of index. + uint16_t char_count = service.characteristic_count; + if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { + ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + return; + } + if (char_count > 0) { + service_resp.characteristics.init(char_count); + for (uint16_t ci = 0; ci < char_count; ci++) { + const auto &chr = table.characteristics[service.first_characteristic + ci]; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids); + characteristic_resp.handle = chr.value_handle; + characteristic_resp.properties = chr.properties; + uint16_t desc_count = chr.descriptor_count; + if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { + ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + return; + } + if (desc_count == 0) { + continue; + } + characteristic_resp.descriptors.init(desc_count); + for (uint16_t di = 0; di < desc_count; di++) { + const auto &desc = table.descriptors[chr.first_descriptor + di]; + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids); + descriptor_resp.handle = desc.handle; + } + } + } + + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped + // (bounded: a subscriber that stays gone ends streaming via the api-lost + // rewind above). + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->send_service_ = batch_start; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h new file mode 100644 index 0000000000..83fbd24e4c --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -0,0 +1,129 @@ +// Hub-platform BluetoothConnection: drives a platform GATT client backend +// through the neutral ble_device_base::BLEGattConnection interface and +// translates its events into the same API messages the esp32 class emits. +// Presents the identical method surface, so the proxy's GATT dispatch +// compiles against either class unchanged. + +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "bluetooth_connection.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/core/helpers.h" + +namespace esphome::bluetooth_proxy { +class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { + +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; + +class BluetoothConnection final : public ble_device_base::GattClientEventListener { + public: + /// Wire the platform backend. Called from codegen before setup. + void set_backend(ble_device_base::BLEGattConnection *backend) { + this->backend_ = backend; + backend->set_listener(this); + } + + // ---- proxy dispatch surface (mirrors the esp32 class) ---- + conn_err_t read_characteristic(uint16_t handle); + conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t read_descriptor(uint16_t handle); + conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t notify_characteristic(uint16_t handle, bool enable); + conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + + /// Start connecting: record the API address type (BLE_ADDR_TYPE_* code + /// space) and open the connection through the backend. Failures report + /// through the same reset path a failed open takes on esp32. + void initiate_connection(uint8_t address_type) { + this->remote_addr_type_ = address_type; + this->start_connect_(); + } + void disconnect(); + // A backend disconnect() is a single call that also cancels an in-progress + // connect; there is no deferred-disconnect state to track. + bool disconnect_pending() const { return false; } + void cancel_pending_disconnect() {} + + void set_address(uint64_t address); + uint64_t get_address() const { return this->address_; } + const char *address_str() const { return this->address_str_; } + uint8_t get_connection_index() const { return this->connection_index_; } + + ClientState state() const { return this->state_; } + void set_state(ClientState st) { this->state_ = st; } + bool connected() const { return this->state_ == ClientState::ESTABLISHED; } + void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } + // Latched at discovery completion rather than read from the backend table: + // streaming frees the table, and this must stay true for the connection's + // lifetime (esp32 parity — a repeat GetServices is silently ignored there, + // never answered with an authoritative empty database). + bool has_gatt_services() const { return this->services_discovered_; } + + /// Stream any pending service-discovery batch and police the disconnect + /// safety timeout. Called from the proxy's loop — hub connections have no + /// Component loop of their own (the esp32 class streams from its own + /// loop() and has the same 10 s safety net in its base class). + void process_pending_services() { + if (this->send_service_ >= 0) { + this->send_service_for_discovery_(); + } + this->check_disconnect_timeout_(); + } + + // ---- ble_device_base::GattClientEventListener ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + + protected: + friend class bluetooth_proxy::BluetoothProxy; + + void start_connect_(); + void send_service_for_discovery_(); + void check_disconnect_timeout_(); + void reset_connection_(conn_err_t reason); + conn_err_t check_connected_op_(const char *action, const char *type) const; + void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); + + // Memory optimized layout for 32-bit systems (a vptr precedes: pointers and + // 2-byte members first fill to an 8-byte boundary before address_) + // Group 1: Pointers (4 bytes each, naturally aligned) + bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; + ble_device_base::BLEGattConnection *backend_{nullptr}; + + // Group 2: 2-byte types + int16_t send_service_{INIT_SENDING_SERVICES}; + uint16_t mtu_{23}; + + // Group 3: 8-byte and 4-byte types + uint64_t address_{0}; + uint32_t disconnecting_started_{0}; + conn_err_t pending_error_{0}; + + // Group 4: Arrays + char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + + // Group 5: 1-byte types + ClientState state_{ClientState::IDLE}; + ConnectionType connection_type_{ConnectionType::V1}; + uint8_t remote_addr_type_{0}; + uint8_t connection_index_{0}; + bool services_discovered_{false}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 5916132ab2..a0706ae4ae 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -47,6 +47,8 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Assistant) assumes an ESPHome proxy can scan actively, so a passive-only # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its FILTER_SOURCE_FILES hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -160,16 +162,14 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All( cv.Schema( { **_COMMON_SCHEMA_KEYS, - # Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator - # after a strict schema rejects an explicit `ble_hub_id` before it - # runs, and that key is the documented way to disambiguate once a - # platform has two trackers. - cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id( - ble_device_base.BLEHub - ), cv.Optional(CONF_ACTIVE, default=False): cv.boolean, } - ).extend(cv.COMPONENT_SCHEMA), + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA), _validate_no_active, ) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 66f22c9a90..a25c9d9608 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -54,8 +54,9 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta #else // !USE_ESP32 void BluetoothProxy::setup() { - this->connections_free_response_.limit = 0; - this->connections_free_response_.free = 0; + // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); @@ -111,16 +112,16 @@ void BluetoothProxy::send_bluetooth_scanner_state_() { #endif // USE_ESP32 -#ifdef USE_ESP32 -void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), - connection->address_str(), espbt::client_state_to_string(state)); + connection->address_str(), ble_device_base::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } -#endif // USE_ESP32 +#endif // BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -188,19 +189,29 @@ void BluetoothProxy::dump_config() { " Connections: %d", YESNO(this->active_), this->connection_count_); #else - // Advertisement-only: print configured facts. dump_config runs right after - // setup, before the radio is up, so live scan state would always read - // "stopped" here — the loop's BluetoothScannerStateResponse carries the - // changing value instead. + // Print configured facts. dump_config runs right after setup, before the + // radio is up, so live scan state would always read "stopped" here — the + // loop's BluetoothScannerStateResponse carries the changing value instead. char mac_str[18]; this->get_bluetooth_mac_address_pretty(mac_str); + const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; + const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" + " Active: %s\n" + " Connections: %d\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + YESNO(this->active_), this->connection_count_, scan_mode, mac_out); +#else ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Mode: advertisement-only (no GATT connections)\n" " Configured scan: %s\n" " Adapter MAC: %s", - this->configured_scan_active_ ? "active" : "passive", - mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"); + scan_mode, mac_out); +#endif #endif } @@ -229,6 +240,51 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } +#endif // USE_ESP32 + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + +// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. +void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { +// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { + // Cannot happen with codegen-sized registration; a silent drop would + // surface later as a null proxy_ dereference, so refuse loudly. + ESP_LOGE(TAG, "Connection registry full, dropping registration"); + return; + } +#ifndef USE_ESP32 + // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub + // class has no Component lifecycle, so the index is assigned here. + connection->connection_index_ = this->connection_count_; +#endif + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; +#endif +} + +void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } + +void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) { + for (auto &slot : this->connections_free_response_.allocated) { + if (slot == find_value) { + slot = set_value; + return; + } + } + // The accounting arrays are only mutated here and sized to the slot count, + // so a miss means the bookkeeping already drifted — say so. + ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + this->send_device_connection(connection->get_address(), false, 0, reason); + connection->set_address(0); + connection->send_service_ = INIT_SENDING_SERVICES; + this->send_connections_free(); +} + BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; @@ -244,7 +300,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese // We only set the state if we allocate the connection // to avoid a race where multiple connection attempts // are made. - connection->set_state(espbt::ClientState::INIT); + connection->set_state(ClientState::INIT); return connection; } } @@ -267,13 +323,12 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, false); return; } - if (connection->state() == espbt::ClientState::CONNECTED || - connection->state() == espbt::ClientState::ESTABLISHED) { + if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::CONNECTING) { + } else if (connection->state() == ClientState::CONNECTING) { if (connection->disconnect_pending()) { ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", connection->get_connection_index(), connection->address_str()); @@ -282,19 +337,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } this->log_connection_request_ignored_(connection, connection->state()); return; - } else if (connection->state() != espbt::ClientState::INIT) { + } else if (connection->state() != ClientState::INIT) { this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { - connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE - connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); + connection->initiate_connection(static_cast(msg.address_type)); this->send_connections_free(); break; } @@ -305,7 +359,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_connections_free(); return; } - if (connection->state() != espbt::ClientState::IDLE) { + if (connection->state() != ClientState::IDLE) { connection->disconnect(); } else { connection->set_address(0); @@ -315,6 +369,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { +#ifdef USE_ESP32 auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { @@ -326,21 +381,21 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_pairing(msg.address, true); } } +#else + // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); + // peripheral-initiated security still works through the platform's SM. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); +#endif break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_remove_bond_device(address); - this->send_device_unpairing(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_gattc_cache_clean(address); - // Shares the sender with the neutral path, which also null-checks api_connection_. - this->send_device_clear_cache(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { @@ -359,7 +414,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms } auto err = connection->read_characteristic(msg.handle); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -372,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & } auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -385,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead } auto err = connection->read_descriptor(msg.handle); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -398,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri } auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -409,8 +464,8 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } - if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); + if (!connection->has_gatt_services()) { + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); this->send_gatt_services_done(msg.address); return; } @@ -426,7 +481,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest } auto err = connection->notify_characteristic(msg.handle, msg.enable); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -434,6 +489,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Send results unchecked (esp32 parity): a drop resolves via the client timeout. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -441,7 +497,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn if (connection == nullptr || !connection->connected()) { ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", - connection ? static_cast(connection->connection_index_) : -1, + connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); @@ -458,6 +514,10 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn this->api_connection_->send_message(resp); } +#endif // BLUETOOTH_CONNECTION_HAS_GATT + +#ifdef USE_ESP32 + void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { if (this->parent_->get_scan_active() == active) { return; @@ -471,21 +531,35 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #else // !USE_ESP32 -// Advertisement-only proxy. GATT client connections are excluded at compile -// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults -// HubCapabilities at runtime today — so every connection-oriented request is -// answered with a clean error instead of silence, and Home Assistant treats -// the proxy as passive. - void BluetoothProxy::loop() { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Stream pending service-discovery batches every iteration (esp32 parity: + // its connections stream from their own per-iteration Component loop). + // send_service_for_discovery_() handles a vanished API connection itself. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->process_pending_services(); + } +#endif + // Run advertisement flush / scanner-state poll every 100ms uint32_t now = App.get_loop_component_start_time(); if (now - this->last_advertisement_flush_time_ < 100) return; this->last_advertisement_flush_time_ = now; - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // The API subscriber is gone: tear down any connections it left behind + // (disconnect() on an already-disconnecting backend is a no-op). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif return; + } // The hub has no scanner-state listener interface; poll and report on change. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -495,6 +569,13 @@ void BluetoothProxy::loop() { this->flush_pending_advertisements_(); } +#ifndef BLUETOOTH_CONNECTION_HAS_GATT + +// Advertisement-only proxy. GATT client connections are excluded at compile +// time (no connection backend on this platform, or active: false), so every +// connection-oriented request is answered with a clean error instead of +// silence, and Home Assistant treats the proxy as passive. + void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { switch (msg.request_type) { case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: @@ -547,12 +628,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Send results unchecked (esp32 parity): a drop resolves via the client timeout. api::BluetoothSetConnectionParamsResponse resp; resp.address = msg.address; resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); } +#endif // !BLUETOOTH_CONNECTION_HAS_GATT + void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { if (this->hub_->scan_active() != active) { ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index dbfc119d98..b8c8ab15f6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -16,7 +16,6 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection.h" #ifdef USE_ESP32 -#include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" @@ -27,6 +26,9 @@ #include #else #include "esphome/components/ble_device_base/ble_hub.h" +#ifdef USE_BLE_GATT_CLIENT +#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" +#endif #endif // USE_ESP32 namespace esphome::bluetooth_proxy { @@ -39,9 +41,9 @@ using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT using BluetoothConnection = bluetooth_connection::BluetoothConnection; -using namespace esp32_ble_client; +using ClientState = ble_device_base::ClientState; #endif // Legacy versions: @@ -51,6 +53,8 @@ using namespace esp32_ble_client; // Version 4: Pairing support // Version 5: Cache clear support static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5; +static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4; +static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3; static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1; enum BluetoothProxyFeature : uint32_t { @@ -72,10 +76,12 @@ enum BluetoothProxySubscriptionFlag : uint32_t { class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public esp32_ble_tracker::BLEScannerStateListener, public Component { - // Allow the connection to update connections_free_response_ - friend bluetooth_connection::BluetoothConnection; #else class BluetoothProxy final : public Component { +#endif +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; #endif public: BluetoothProxy(); @@ -90,25 +96,17 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef USE_ESP32 - // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. - void register_connection([[maybe_unused]] BluetoothConnection *connection) { - // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 - if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { - this->connections_[this->connection_count_++] = connection; - connection->proxy_ = this; - } -#endif - } -#else +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + void register_connection(BluetoothConnection *connection); +#endif // BLUETOOTH_CONNECTION_HAS_GATT +#ifndef USE_ESP32 void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and // the BLEHub contract does not promise those are settled any earlier than // the hub's own setup(). float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } -#endif // USE_ESP32 +#endif // !USE_ESP32 void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); @@ -122,6 +120,10 @@ class BluetoothProxy final : public Component { 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_; } + /// Whether the subscribed API client understands 16/32-bit UUID fields. + bool client_supports_efficient_uuids() const { + return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); + } void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); @@ -134,17 +136,6 @@ class BluetoothProxy final : public Component { void bluetooth_scanner_set_mode(bool active); -#ifdef USE_ESP32 - static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) { - bd_addr[0] = (address >> 40) & 0xff; - bd_addr[1] = (address >> 32) & 0xff; - bd_addr[2] = (address >> 24) & 0xff; - bd_addr[3] = (address >> 16) & 0xff; - bd_addr[4] = (address >> 8) & 0xff; - bd_addr[5] = (address >> 0) & 0xff; - } -#endif - void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } @@ -154,10 +145,17 @@ class BluetoothProxy final : public Component { #endif uint32_t get_legacy_version() const { - if (this->active_) { + if (!this->active_) { + return LEGACY_PASSIVE_ONLY_VERSION; + } + // Legacy clients (which predate the feature flags) map versions to + // capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active + // connections only. + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; } - return LEGACY_PASSIVE_ONLY_VERSION; + return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION + : LEGACY_ACTIVE_NO_PAIRING_VERSION; } uint32_t get_feature_flags() const { @@ -176,11 +174,18 @@ class BluetoothProxy final : public Component { } #endif if (this->active_) { + // REMOTE_CACHING is mandatory for active connections: API clients + // refuse to connect without it (it selects which V3 connect request + // they send, not device-side caching). flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS; flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING; - flags |= BluetoothProxyFeature::FEATURE_PAIRING; - flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING; + if (bluetooth_connection::SUPPORTS_PAIRING) { + flags |= BluetoothProxyFeature::FEATURE_PAIRING; + } + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { + flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; + } } return flags; @@ -231,22 +236,59 @@ class BluetoothProxy final : public Component { } void log_advertisement_flush_(); -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT BluetoothConnection *get_connection_(uint64_t address, bool reserve); - void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); #endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + /// Keep the pre-allocated connections-free message in step when a + /// connection slot changes address (0 = free). Called from the connection + /// classes' set_address(). + // maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the + // body is removed, and the free < MAX compare would trip -Wtype-limits. + void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) { +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + auto &resp = this->connections_free_response_; + if (new_address == 0 && old_address != 0) { + if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + resp.free++; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(old_address, 0); + } else if (new_address != 0 && old_address == 0) { + if (resp.free > 0) { + resp.free--; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(0, new_address); + } +#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + } + void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_slot_accounting_mismatch_(); + /// Free a connection slot after teardown: notify the API client and reset + /// the streaming cursor. Important: does NOT send send_gatt_services_done() + /// when service streaming was interrupted -- the client (aioesphomeapi) has + /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service + /// discovery and retry, rather than being told a partial list is complete. + void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); +#endif + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT // Group 2: Fixed-size array of connection pointers std::array connections_{}; -#else +#endif +#ifndef USE_ESP32 ble_device_base::BLEHub *hub_{nullptr}; #endif diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index c474b5fa81..036530d942 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -5,12 +5,14 @@ advertisement-only arm applies its own defaults.""" import pytest from esphome import config_validation as cv -from esphome.components import bluetooth_connection, bluetooth_proxy +from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_LN882X, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE @@ -22,12 +24,18 @@ HUB_PLATFORM_FRAMEWORKS = [ PlatformFramework.RP2_ARDUINO, ] +HUB_TRACKERS = { + PLATFORM_LN882X: "ln882h_ble_tracker", + PLATFORM_RP2: "rp2_ble_tracker", +} + def test_hub_platform_list_covers_every_hub_platform() -> None: # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise # get no gate coverage at all. covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS) def _set_platform(platform: str | None) -> None: @@ -35,6 +43,14 @@ def _set_platform(platform: str | None) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform +def _register_tracker(platform: str) -> None: + # The ble_hub_id guard needs a loaded tracker, normally registered as an + # import side effect of the tracker module. + tracker = HUB_TRACKERS[platform] + ble_device_base.register_hub_provider(tracker) + CORE.loaded_integrations.add(tracker) + + def test_ble_less_platform_gets_the_real_reason( set_core_config: SetCoreConfigCallable, ) -> None: @@ -68,6 +84,7 @@ def test_hub_platform_rejects_active( platform_framework: PlatformFramework, ) -> None: set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) with pytest.raises(cv.Invalid, match="Active connections are not supported"): bluetooth_proxy.CONFIG_SCHEMA({"active": True}) @@ -100,6 +117,7 @@ def test_hub_platform_accepts_the_advertisement_only_shape( platform_framework: PlatformFramework, ) -> None: set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False diff --git a/tests/components/bluetooth_connection/test_gatt_uuid.cpp b/tests/components/bluetooth_connection/test_gatt_uuid.cpp new file mode 100644 index 0000000000..b3596a4364 --- /dev/null +++ b/tests/components/bluetooth_connection/test_gatt_uuid.cpp @@ -0,0 +1,49 @@ +// Pins the shared UUID wire packing and the size-estimate budget the service +// streamers rely on, in both efficient and legacy client modes. +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +namespace esphome::bluetooth_connection::testing { + +using ble_device_base::ESPBTUUID; + +TEST(GattUuidPacking, ShortUuidUsedWhenClientSupportsIt) { + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), true); + EXPECT_EQ(short_uuid, 0x180Fu); + EXPECT_EQ(uuid128[0], 0u); + EXPECT_EQ(uuid128[1], 0u); +} + +TEST(GattUuidPacking, LegacyClientGetsBaseUuidExpansion) { + // 0000180F-0000-1000-8000-00805F9B34FB + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), false); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x0000180F00001000ULL); + EXPECT_EQ(uuid128[1], 0x800000805F9B34FBULL); +} + +TEST(GattUuidPacking, FullUuidPassesThroughBigEndian) { + // 12345678-90AB-CDEF-1122-334455667788, stored little-endian in ESPBTUUID. + const uint8_t big_endian[16] = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; + std::array uuid128{}; + uint32_t short_uuid = 0; + // Efficient mode must still use the 128-bit form for 128-bit UUIDs. + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_raw_reversed(big_endian), true); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x1234567890ABCDEFULL); + EXPECT_EQ(uuid128[1], 0x1122334455667788ULL); +} + +TEST(GattUuidPacking, EstimateGrowsWithCharacteristicsAndMode) { + // The estimate only gates batching; pin its shape, not exact bytes. + EXPECT_LT(estimate_service_size(0, true), estimate_service_size(0, false)); + EXPECT_LT(estimate_service_size(1, false), estimate_service_size(2, false)); +} + +} // namespace esphome::bluetooth_connection::testing From c0e70d9beb09f09c283c38db10c6478a9139047e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 11:50:18 -0500 Subject: [PATCH 008/597] [rp2040_ble] Add scan arbitration and GATT client hooks (#18155) --- esphome/components/rp2040_ble/rp2040_ble.cpp | 52 ++++++++++++++++++++ esphome/components/rp2040_ble/rp2040_ble.h | 28 +++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 8e7c7d6be5..7dd84d9c31 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -57,6 +57,17 @@ void RP2040BLE::enable() { l2cap_init(); sm_init(); +#ifdef USE_BLE_GATT_CLIENT + gatt_client_init(); + // The GATT engine kicks the MTU exchange explicitly right after a + // connection completes (auto negotiation would only run on the first + // query, which a with-cache connection never issues). + gatt_client_mtu_enable_auto_negotiation(0); + // Just-works security for peripheral-initiated pairing. + sm_set_io_capabilities(IO_CAPABILITY_NO_INPUT_NO_OUTPUT); + sm_set_authentication_requirements(SM_AUTHREQ_BONDING); +#endif + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler; hci_add_event_handler(&this->hci_event_callback_registration_); @@ -215,6 +226,18 @@ bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { // the moment a tracker retries. Callers retry until the stack is up. return false; } +#ifdef USE_BLE_GATT_CLIENT + this->scan_interval_ = interval; + this->scan_window_ = window; + this->scan_active_mode_ = active; + this->scan_desired_ = true; + if (this->scan_inhibit_count_ > 0) { + // A connect attempt owns the radio; the scan starts physically when the + // inhibit is released. Report success — the controller will run it. + ESP_LOGV(TAG, "Scan start deferred (connect in progress)"); + return true; + } +#endif // Serialize with the BTstack background worker (arduino-pico's BluetoothHCI // takes the same lock around its gap_* calls). BluetoothLock lock; @@ -224,6 +247,9 @@ bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { } void RP2040BLE::scan_stop() { +#ifdef USE_BLE_GATT_CLIENT + this->scan_desired_ = false; +#endif if (!this->is_active()) { return; // nothing can be scanning on a stack that is not up } @@ -231,6 +257,32 @@ void RP2040BLE::scan_stop() { gap_stop_scan(); } +#ifdef USE_BLE_GATT_CLIENT +void RP2040BLE::inhibit_scan() { + if (this->scan_inhibit_count_++ != 0) { + return; // another connect attempt already owns the radio + } + if (this->scan_desired_ && this->is_active()) { + BluetoothLock lock; + gap_stop_scan(); + } +} + +void RP2040BLE::release_scan_inhibit() { + if (this->scan_inhibit_count_ == 0) { + return; + } + this->scan_inhibit_count_--; + if (this->scan_inhibit_count_ != 0) { + return; + } + if (this->scan_desired_) { + // One physical-start path: scan_start re-applies the remembered params. + this->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_mode_); + } +} +#endif // USE_BLE_GATT_CLIENT + } // namespace esphome::rp2040_ble #endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index cc015c0503..99eb8cd88a 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -91,13 +91,25 @@ class RP2040BLE final : public Component { /// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the /// tracker's rate-limited retry loop); powering the stack on stays with the /// user (enable_on_boot or an explicit enable() call). The controller keeps - /// no scan state: a disable()/enable() power cycle ends the scan, and the - /// caller must call scan_start() again once the stack is back to ACTIVE - /// (the tracker's loop() reconciliation does exactly that). + /// no scan state across power cycles: a disable()/enable() cycle ends the + /// scan, and the caller must call scan_start() again once the stack is back + /// to ACTIVE (the tracker's loop() reconciliation does exactly that). + /// While a GATT connect attempt has the scan inhibited, the desired scan is + /// remembered and started physically when the inhibit is released. bool scan_start(uint16_t interval, uint16_t window, bool active); /// Stop the controller scan (no-op when not scanning). void scan_stop(); +#ifdef USE_BLE_GATT_CLIENT + /// Pause the physical scan for the duration of a GATT connect attempt + /// (initiating and scanning contend for the radio). The desired scan state + /// set through scan_start()/scan_stop() is remembered and reconciled by + /// release_scan_inhibit(). Holders must guarantee the release on every + /// abort path (the GATT engine reclaims via its connect timeout). + void inhibit_scan(); + void release_scan_inhibit(); +#endif + protected: static void packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); @@ -128,6 +140,16 @@ class RP2040BLE final : public Component { bool enable_on_boot_{true}; bool btstack_initialized_{false}; bool active_logged_{false}; +#ifdef USE_BLE_GATT_CLIENT + // Remembered scan intent, so connect attempts can pause the physical scan + // and restore it afterwards without involving the tracker. Counted so + // overlapping connect attempts compose once multiple slots exist. + uint16_t scan_interval_{0}; + uint16_t scan_window_{0}; + uint8_t scan_inhibit_count_{0}; + bool scan_active_mode_{false}; + bool scan_desired_{false}; +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From b326cefea7bd19dea0034491556fb61dacc20057 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 7 Aug 2026 19:36:59 +0200 Subject: [PATCH 009/597] [esp32] refactor esp32-vfs default configs to FINAL co-routine & add some defaults (#17337) Co-authored-by: Oliver Kleinecke Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: storage split Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 118 +++++++++------- tests/component_tests/esp32/test_esp32.py | 159 ++++++++++++++++++++++ 2 files changed, 225 insertions(+), 52 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e31d0352e9..d16e8ae03c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2248,6 +2248,62 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_vfs_fatfs_sdkconfig( + disable_vfs_termios: bool, + disable_vfs_select: bool, + disable_vfs_dir: bool, + disable_fatfs: bool, +) -> None: + """Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win.""" + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off. + if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True) + else: + set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios) + + # VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread); + # sockets use lwip_select() either way. ~2.7KB flash when off. + if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_SELECT", True) + else: + set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select) + + # Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off. + if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): + set_opt("CONFIG_VFS_SUPPORT_DIR", True) + else: + set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir) + + # FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only; + # sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set + # any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack. + lfn_keys = ( + "CONFIG_FATFS_LFN_NONE", + "CONFIG_FATFS_LFN_HEAP", + "CONFIG_FATFS_LFN_STACK", + ) + user_picked_lfn = any(k in opts for k in lfn_keys) + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", False) + set_opt("CONFIG_FATFS_LFN_HEAP", True) + set_opt("CONFIG_FATFS_MAX_LFN", 255) + set_opt("CONFIG_FATFS_VOLUME_COUNT", 4) + elif disable_fatfs: + if not user_picked_lfn: + set_opt("CONFIG_FATFS_LFN_NONE", True) + # Kconfig range is [1,10]; 0 gets clamped to the default. + set_opt("CONFIG_FATFS_VOLUME_COUNT", 1) + + @coroutine_with_priority(CoroPriority.FINAL - 1) async def _finalize_arduino_aware_flags(): """Build flags that depend on whether arduino-esp32 is linked in. @@ -2603,47 +2659,6 @@ async def to_code(config): if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) - # Disable VFS support for termios (terminal I/O functions) - # USB Serial JTAG VFS functions require termios support. - # Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected - # as the logger output) call require_vfs_termios(). - # Saves approximately 1.8KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): - # Component requires VFS termios - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] - ) - - # Disable VFS support for select() with file descriptors - # ESPHome only uses select() with sockets via lwip_select(), which still works. - # VFS select is only needed for UART/eventfd file descriptors. - # Components that need it (e.g., openthread) call require_vfs_select(). - # Saves approximately 2.7KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): - # Component requires VFS select - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] - ) - - # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) - # ESPHome doesn't use directory functions on ESP32. - # Components that need it (e.g., storage components) call require_vfs_dir(). - # Saves approximately 0.5KB+ of flash when disabled (default). - if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): - # Component requires VFS directory support - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] - ) - if use_platformio: cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: @@ -2878,6 +2893,16 @@ async def to_code(config): # FINAL priority: runs after every network/coexistence request_*() call CORE.add_job(_reconcile_network_sdkconfig) + # FINAL: require_*() calls can come from to_code at or below this priority, so an + # inline read would be iteration-order-dependent; reconcile once after every job ran. + CORE.add_job( + _reconcile_vfs_fatfs_sdkconfig, + advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS], + advanced[CONF_DISABLE_VFS_SUPPORT_SELECT], + advanced[CONF_DISABLE_VFS_SUPPORT_DIR], + advanced[CONF_DISABLE_FATFS], + ) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: @@ -2893,17 +2918,6 @@ async def to_code(config): ): add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) - # Disable FATFS support - # Components that need FATFS (SD card, etc.) can call require_fatfs() - if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): - # Component called require_fatfs() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) - elif advanced[CONF_DISABLE_FATFS]: - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - # Kconfig range is [1,10]; 0 gets clamped to the default. - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) - for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 5620a220f8..1fd835076d 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,11 +10,16 @@ from typing import Any import pytest from esphome.components.esp32 import ( + KEY_FATFS_REQUIRED, + KEY_VFS_DIR_REQUIRED, + KEY_VFS_SELECT_REQUIRED, + KEY_VFS_TERMIOS_REQUIRED, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, _ota_downgrade_protection_errors, _reconcile_network_sdkconfig, + _reconcile_vfs_fatfs_sdkconfig, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -614,6 +619,160 @@ def test_reconcile_network_sdkconfig( assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected +@pytest.mark.parametrize( + ("requires", "fatfs_required", "disables", "preset", "expected"), + [ + # Nothing required and every disable_* flag off (NOT the shipped defaults, which + # disable everything): VFS enabled, FATFS left untouched entirely. + pytest.param( + {}, + False, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="nothing_disabled_nothing_required", + ), + # The shipped out-of-the-box path: every disable_* flag defaults to True and nothing + # is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume). + pytest.param( + {}, + False, + (True, True, True, True), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": False, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": False, + "CONFIG_FATFS_LFN_NONE": True, + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="all_disabled_fatfs_fallback", + ), + # A component's require_* beats the user's disable_* flag for every VFS feature. + pytest.param( + { + KEY_VFS_TERMIOS_REQUIRED: True, + KEY_VFS_SELECT_REQUIRED: True, + KEY_VFS_DIR_REQUIRED: True, + }, + False, + (True, True, True, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="require_beats_disable", + ), + # A user sdkconfig_options preset wins over a require (the set_opt guard). + pytest.param( + {KEY_VFS_SELECT_REQUIRED: True}, + False, + (False, False, False, False), + {"CONFIG_VFS_SUPPORT_SELECT": False}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": False, + "CONFIG_VFS_SUPPORT_DIR": True, + }, + id="user_preset_wins_over_require", + ), + # require_fatfs() with no user preset: long filenames on the heap, 255 chars, + # four volumes. + pytest.param( + {}, + True, + (False, False, False, False), + {}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": False, + "CONFIG_FATFS_LFN_HEAP": True, + "CONFIG_FATFS_MAX_LFN": 255, + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_required_defaults", + ), + # CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member + # (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_STACK": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_STACK": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_stack_untouched", + ), + # disable_fatfs (the shipped default) with a user LFN pick: the choice group is the + # user's -- no LFN_NONE=y written next to their member, only the volume fallback. + pytest.param( + {}, + False, + (False, False, False, True), + {"CONFIG_FATFS_LFN_HEAP": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_HEAP": "y", + "CONFIG_FATFS_VOLUME_COUNT": 1, + }, + id="disable_fatfs_user_lfn_untouched", + ), + # Same for an explicit LFN_NONE preset: the group is the user's, only the volume + # count default is added. + pytest.param( + {}, + True, + (False, False, False, False), + {"CONFIG_FATFS_LFN_NONE": "y"}, + { + "CONFIG_VFS_SUPPORT_TERMIOS": True, + "CONFIG_VFS_SUPPORT_SELECT": True, + "CONFIG_VFS_SUPPORT_DIR": True, + "CONFIG_FATFS_LFN_NONE": "y", + "CONFIG_FATFS_VOLUME_COUNT": 4, + }, + id="fatfs_user_lfn_none_untouched", + ), + ], +) +def test_reconcile_vfs_fatfs_sdkconfig( + set_core_config: SetCoreConfigCallable, + requires: dict[str, bool], + fatfs_required: bool, + disables: tuple[bool, bool, bool, bool], + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves the VFS feature flags and the FATFS + defaults from the recorded require_* calls, with user sdkconfig_options winning + and the LFN Kconfig choice treated as one group.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)} + if fatfs_required: + CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True + for key, value in requires.items(): + CORE.data[key] = value + + asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables)) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + def test_network_wifi_only_reconciles_end_to_end( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From 77bfda6f1fcf59fc536393c96aaded65e0514e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 20:48:18 +0300 Subject: [PATCH 010/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 2: atc_mithermometer, pvvx_mithermometer, bthome_mithermometer) (#17950) --- .../atc_mithermometer/atc_mithermometer.cpp | 8 +--- .../atc_mithermometer/atc_mithermometer.h | 12 ++---- .../components/atc_mithermometer/sensor.py | 13 +++--- .../bthome_mithermometer/__init__.py | 14 +++---- .../bthome_mithermometer/bthome_ble.cpp | 42 ++++++++----------- .../bthome_mithermometer/bthome_ble.h | 17 ++++---- .../components/bthome_mithermometer/sensor.py | 4 +- .../display/pvvx_display.cpp | 13 +++--- .../pvvx_mithermometer/display/pvvx_display.h | 16 +++---- .../pvvx_mithermometer/pvvx_mithermometer.cpp | 8 +--- .../pvvx_mithermometer/pvvx_mithermometer.h | 12 ++---- .../components/pvvx_mithermometer/sensor.py | 13 +++--- .../atc_mithermometer/common-ln.yaml | 7 ++++ .../components/atc_mithermometer/common.yaml | 3 ++ .../atc_mithermometer/test.ln882x-ard.yaml | 3 ++ .../validate-legacy-key.esp32-idf.yaml | 10 +++++ .../validate.bk72xx-ard.yaml | 17 ++++++++ .../bthome_mithermometer/common-ln.yaml | 9 ++++ .../bthome_mithermometer/common.yaml | 3 ++ .../bthome_mithermometer/test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 18 ++++++++ .../pvvx_mithermometer/common-ln.yaml | 8 ++++ .../components/pvvx_mithermometer/common.yaml | 3 ++ .../pvvx_mithermometer/test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 13 ++++++ 25 files changed, 177 insertions(+), 95 deletions(-) create mode 100644 tests/components/atc_mithermometer/common-ln.yaml create mode 100644 tests/components/atc_mithermometer/test.ln882x-ard.yaml create mode 100644 tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml create mode 100644 tests/components/atc_mithermometer/validate.bk72xx-ard.yaml create mode 100644 tests/components/bthome_mithermometer/common-ln.yaml create mode 100644 tests/components/bthome_mithermometer/test.ln882x-ard.yaml create mode 100644 tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml create mode 100644 tests/components/pvvx_mithermometer/common-ln.yaml create mode 100644 tests/components/pvvx_mithermometer/test.ln882x-ard.yaml create mode 100644 tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 7b5cdcfa20..22cb2b3150 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,8 +1,6 @@ #include "atc_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { static const char *const TAG = "atc_mithermometer"; @@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device return success; } -optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -132,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional &result, cons } } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 0f472c11b9..3f5ca4c784 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -40,11 +38,9 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 5286d29d1b..5c2d75753c 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -24,14 +24,15 @@ from esphome.const import ( CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer") ATCMiThermometer = atc_mithermometer_ns.class_( - "ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("atc_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(ATCMiThermometer), @@ -71,15 +72,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 8ce216da22..4be7ca8268 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -1,24 +1,24 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt CODEOWNERS = ["@nagyrobi"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] -BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") BTHomeMiThermometer = bthome_mithermometer_ns.class_( - "BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BTHomeMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) def bthome_mithermometer_base_schema(extra_schema=None): if extra_schema is None: extra_schema = {} - return ( + return cv.All( + ble_device_base.rename_legacy_hub_id("bthome_mithermometer"), cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer), @@ -26,15 +26,15 @@ def bthome_mithermometer_base_schema(extra_schema=None): cv.Optional(CONF_BINDKEY): cv.bind_key, } ) - .extend(BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) .extend(extra_schema) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def setup_bthome_mithermometer(var, config): await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if bindkey := config.get(CONF_BINDKEY): bindkey_bytes = [ diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 66f147c266..1ebabea0a3 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -8,13 +8,20 @@ #include #include +// AES-CCM backend for encrypted-advertisement (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code +// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform. #ifdef USE_ESP32 - #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define BTHOME_CRYPTO_PSA +#endif +#endif +#ifndef BTHOME_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::bthome_mithermometer { @@ -157,7 +164,7 @@ void BTHomeMiThermometer::dump_config() { LOG_SENSOR(" ", "Signal Strength", this->signal_strength_); } -bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { bool matched = false; for (auto &service_data : device.get_service_datas()) { if (this->handle_service_data_(service_data, device)) { @@ -204,7 +211,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da const uint8_t *ciphertext = data.data() + 1; const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if defined(BTHOME_CRYPTO_PSA) // PSA AEAD expects ciphertext + tag concatenated // BLE advertisement max payload is 31 bytes, so this is always sufficient static constexpr size_t MAX_CT_WITH_TAG = 32; @@ -236,29 +243,18 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da return false; } #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->bindkey_, BTHOME_BINDKEY_SIZE * 8); - if (ret) { - ESP_LOGVV(TAG, "mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_size, nonce.data(), nonce.size(), nullptr, 0, ciphertext, - payload.data(), mic, BTHOME_MIC_SIZE); - mbedtls_ccm_free(&ctx); - if (ret) { - ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext, + ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) { + ESP_LOGVV(TAG, "BTHome decryption failed."); return false; } #endif return true; } -bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device) { if (!service_data.uuid.contains(0xD2, 0xFC)) { return false; } @@ -439,5 +435,3 @@ bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceD } } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 924858e449..4a95311557 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" @@ -8,11 +8,12 @@ #include #include -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform; this +// component is only compiled when configured (which requires a BLE hub). bindkey (AES-CCM) +// decryption availability is selected per platform in the .cpp. namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); @@ -24,11 +25,11 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void dump_config() override; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; protected: - bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device); + bool handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device); bool decrypt_bthome_payload_(const std::vector &data, uint64_t source_address, std::vector &payload) const; @@ -45,5 +46,3 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, }; } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 9b50866db0..02551391ad 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -23,9 +23,9 @@ from esphome.const import ( from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer -CODEOWNERS = ["@nagyrobi"] +AUTO_LOAD = ["ble_device_base"] -DEPENDENCIES = ["esp32_ble_tracker"] +CODEOWNERS = ["@nagyrobi"] CONFIG_SCHEMA = bthome_mithermometer_base_schema( { diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 7a6be40d6c..64b8974901 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -1,16 +1,17 @@ -#include "pvvx_display.h" -#include "esphome/components/esp32_ble/ble_uuid.h" -#include "esphome/core/log.h" +#include "esphome/core/defines.h" #ifdef USE_ESP32 +#include "pvvx_display.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/core/log.h" namespace esphome::pvvx_mithermometer { static const char *const TAG = "display.pvvx_mithermometer"; void PVVXDisplay::dump_config() { - char service_buf[esp32_ble::UUID_STR_LEN]; - char char_buf[esp32_ble::UUID_STR_LEN]; + char service_buf[ble_device_base::UUID_STR_LEN]; + char char_buf[ble_device_base::UUID_STR_LEN]; ESP_LOGCONFIG(TAG, "PVVX MiThermometer display:\n" " MAC address : %s\n" @@ -188,4 +189,4 @@ void PVVXDisplay::sync_time_() { } // namespace esphome::pvvx_mithermometer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index d231111c58..c3f6028423 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -1,13 +1,16 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" #include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/display/display.h" #include -#ifdef USE_ESP32 #include #ifdef USE_TIME #include "esphome/components/time/real_time_clock.h" @@ -121,14 +124,13 @@ class PVVXDisplay final : public ble_client::BLEClientNode, public PollingCompon uint16_t char_handle_ = 0; bool connection_established_ = false; - esp32_ble_tracker::ESPBTUUID service_uuid_ = - esp32_ble_tracker::ESPBTUUID::from_raw("00001f10-0000-1000-8000-00805f9b34fb"); - esp32_ble_tracker::ESPBTUUID char_uuid_ = - esp32_ble_tracker::ESPBTUUID::from_raw("00001f1f-0000-1000-8000-00805f9b34fb"); + ble_device_base::ESPBTUUID service_uuid_ = + ble_device_base::ESPBTUUID::from_raw("00001f10-0000-1000-8000-00805f9b34fb"); + ble_device_base::ESPBTUUID char_uuid_ = ble_device_base::ESPBTUUID::from_raw("00001f1f-0000-1000-8000-00805f9b34fb"); pvvx_writer_t writer_{}; }; } // namespace esphome::pvvx_mithermometer -#endif +#endif // USE_ESP32 diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index f674fc3694..9141d12b16 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -1,8 +1,6 @@ #include "pvvx_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::pvvx_mithermometer { static const char *const TAG = "pvvx_mithermometer"; @@ -15,7 +13,7 @@ void PVVXMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool PVVXMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool PVVXMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool PVVXMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return success; } -optional PVVXMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional PVVXMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -140,5 +138,3 @@ bool PVVXMiThermometer::report_results_(const optional &result, con } } // namespace esphome::pvvx_mithermometer - -#endif diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index 382e41d210..7a2244207b 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::pvvx_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -40,11 +38,9 @@ class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPB uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::pvvx_mithermometer - -#endif diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index da57c65341..ee5b19ea77 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -24,14 +24,15 @@ from esphome.const import ( CODEOWNERS = ["@pasiz"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] pvvx_mithermometer_ns = cg.esphome_ns.namespace("pvvx_mithermometer") PVVXMiThermometer = pvvx_mithermometer_ns.class_( - "PVVXMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "PVVXMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("pvvx_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(PVVXMiThermometer), @@ -71,15 +72,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/tests/components/atc_mithermometer/common-ln.yaml b/tests/components/atc_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..78787099dc --- /dev/null +++ b/tests/components/atc_mithermometer/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: atc_mithermometer + mac_address: A4:C1:38:4E:16:78 + temperature: + name: ATC Temperature + humidity: + name: ATC Humidity diff --git a/tests/components/atc_mithermometer/common.yaml b/tests/components/atc_mithermometer/common.yaml index 0248090c23..c6da2fa173 100644 --- a/tests/components/atc_mithermometer/common.yaml +++ b/tests/components/atc_mithermometer/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: atc_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 temperature: name: ATC Temperature diff --git a/tests/components/atc_mithermometer/test.ln882x-ard.yaml b/tests/components/atc_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..144ba0e3f8 --- /dev/null +++ b/tests/components/atc_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + atc_mithermometer: !include common-ln.yaml diff --git a/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml b/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml new file mode 100644 index 0000000000..44bc401caa --- /dev/null +++ b/tests/components/atc_mithermometer/validate-legacy-key.esp32-idf.yaml @@ -0,0 +1,10 @@ +# The esp32_ble_id -> ble_hub_id alias (removal 2027.2.0) still validates. +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + - platform: atc_mithermometer + esp32_ble_id: ble_tracker_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: ATC Legacy Key Temperature diff --git a/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml b/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..7528dbf8dd --- /dev/null +++ b/tests/components/atc_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,17 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: atc_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: BK ATC Temperature + # No ble_hub_id: exercises the generated binding real configs use. + - platform: atc_mithermometer + mac_address: A4:C1:38:4E:16:79 + temperature: + name: BK ATC Implicit Temperature diff --git a/tests/components/bthome_mithermometer/common-ln.yaml b/tests/components/bthome_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..947f8890f8 --- /dev/null +++ b/tests/components/bthome_mithermometer/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: bthome_mithermometer + mac_address: A4:C1:38:4E:16:78 + # bindkey compiles ble_device_base::aes_ccm_auth_decrypt off Espressif + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BTHome Temperature + humidity: + name: BTHome Humidity diff --git a/tests/components/bthome_mithermometer/common.yaml b/tests/components/bthome_mithermometer/common.yaml index 7a68fae966..d61738bbe5 100644 --- a/tests/components/bthome_mithermometer/common.yaml +++ b/tests/components/bthome_mithermometer/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: bthome_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: eef418daf699a0c188f3bfd17e4565d9 temperature: diff --git a/tests/components/bthome_mithermometer/test.ln882x-ard.yaml b/tests/components/bthome_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..d03ff4a4d2 --- /dev/null +++ b/tests/components/bthome_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + bthome_mithermometer: !include common-ln.yaml diff --git a/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml b/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..4a50dd6e1a --- /dev/null +++ b/tests/components/bthome_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,18 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: bthome_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BK BTHome Temperature + # No ble_hub_id: exercises the generated binding real configs use. + - platform: bthome_mithermometer + mac_address: A4:C1:38:4E:16:79 + temperature: + name: BK BTHome Implicit Temperature diff --git a/tests/components/pvvx_mithermometer/common-ln.yaml b/tests/components/pvvx_mithermometer/common-ln.yaml new file mode 100644 index 0000000000..b40f8cfd53 --- /dev/null +++ b/tests/components/pvvx_mithermometer/common-ln.yaml @@ -0,0 +1,8 @@ +# Sensor only: the pvvx display is a GATT client and stays ESP32-only. +sensor: + - platform: pvvx_mithermometer + mac_address: A4:C1:38:4E:16:78 + temperature: + name: PVVX Temperature + humidity: + name: PVVX Humidity diff --git a/tests/components/pvvx_mithermometer/common.yaml b/tests/components/pvvx_mithermometer/common.yaml index 972f23122c..8e3e8284a6 100644 --- a/tests/components/pvvx_mithermometer/common.yaml +++ b/tests/components/pvvx_mithermometer/common.yaml @@ -3,6 +3,7 @@ wifi: password: password1 esp32_ble_tracker: + id: ble_tracker_hub ble_client: - mac_address: 01:02:03:04:05:06 @@ -26,7 +27,9 @@ display: it.print_battery(true); sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: pvvx_mithermometer + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 temperature: name: PVVX Temperature diff --git a/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml b/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8bf34382d4 --- /dev/null +++ b/tests/components/pvvx_mithermometer/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + pvvx_mithermometer: !include common-ln.yaml diff --git a/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml b/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..756ebdf79d --- /dev/null +++ b/tests/components/pvvx_mithermometer/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. The display platform is excluded: it needs ble_client +# (GATT), which only the esp32 tracker stack provides. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: pvvx_mithermometer + ble_hub_id: ble_hub + mac_address: A4:C1:38:4E:16:78 + temperature: + name: BK PVVX Temperature From d7b5ad77daacd1b63ce7a888f6b23b4adb8a339f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 12:53:09 -0500 Subject: [PATCH 011/597] [bluetooth_connection] Add BTstack GATT client backend for rp2 (#18131) --- .../ble_device_base/ble_client_state.h | 18 + esphome/components/ble_device_base/ble_hub.h | 2 +- .../bluetooth_connection/__init__.py | 20 +- .../bluetooth_connection_hub.cpp | 12 +- .../bluetooth_connection_rp2.cpp | 970 ++++++++++++++++++ .../bluetooth_connection_rp2.h | 206 ++++ .../components/bluetooth_proxy/__init__.py | 2 +- .../esp32_ble_client/ble_client_base.cpp | 22 +- .../rp2_ble_tracker/rp2_ble_tracker.h | 10 +- 9 files changed, 1238 insertions(+), 24 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_rp2.h diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index 58f7d84fad..b0c91397fc 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -18,6 +18,24 @@ namespace esphome::ble_device_base { static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +// Preferred connection parameters shared by every platform's GATT client so +// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency +// 0). FAST covers connection setup and service discovery; MEDIUM is the +// steady state once established. Stack defaults (12.5-15 ms) are too slow for +// stable connections through WiFi-based BLE proxies, causing disconnections; +// MEDIUM balances responsiveness with bandwidth usage. +static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static constexpr 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. +static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s + +// Fastest connection parameters for devices with short discovery timeouts +static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s + enum class ClientState : uint8_t { // Connection is allocated INIT, diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 3870e9833e..b6fcf6f57a 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -58,7 +58,7 @@ struct HubCapabilities { bool merges_scan_response; /// GATT client connections are available: the platform has a /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection - /// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend. + /// (ble_gatt_client.h). Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index d4d0a3c3af..1dc1969a6a 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,14 +1,15 @@ """Per-platform GATT connection backends the Bluetooth proxy drives. -Auto-loaded by bluetooth_proxy, no user-facing configuration; the proxy's -codegen declares and registers the connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no +user-facing configuration; the proxy's codegen declares and registers the +connection instances. """ import functools import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PlatformFramework +from esphome.const import PLATFORM_RP2, PlatformFramework from esphome.core import CORE @@ -24,9 +25,17 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") -# The hub-platform wrapper codegen class (drives a ble_device_base -# BLEGattConnection backend; see bluetooth_connection_hub.h). +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1; +# raising this needs an upstream change (the layer itself supports N). +RP2_MAX_CONNECTIONS = 1 + +# Hub platforms with a GATT backend, mapped to their slot limit — the single +# registry of which hub platforms run the connection-capable proxy. +HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} + +# The hub-platform wrapper and the rp2 BTstack backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") +RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) @functools.cache @@ -53,5 +62,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RP2_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 338bf671a8..37d6b21dfe 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -117,8 +117,18 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (connected) { this->mtu_ = mtu; if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { - // The API client has the services cached; never discover them. + // The API client has the services cached; never discover them. No + // discovery phase needs the fast interval, so settle straight into the + // shared steady-state parameters (same lifecycle place as esp32). this->state_ = ClientState::ESTABLISHED; + int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, + ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, + ble_device_base::MEDIUM_CONN_TIMEOUT); + if (param_err != 0) { + // Survivable: the link just stays on the fast interval. + ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, + param_err); + } this->proxy_->send_device_connection(this->address_, true, mtu); this->proxy_->send_connections_free(); return; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp new file mode 100644 index 0000000000..ecd7a9713a --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -0,0 +1,970 @@ +#include "bluetooth_connection_rp2.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.rp2"; + +using ble_device_base::ESPBTUUID; +using ble_device_base::GATT_ERR_NOT_CONNECTED; +using ble_device_base::GATT_ERR_NO_MEMORY; + +// Engine-owned timeouts: BTstack has a 30 s ATT transaction timeout but no +// connect timeout — a stuck LE_CONNECTING both blocks future gap_connect calls +// and keeps the scan inhibited, so the engine cancels after 20 s. The +// disconnect timeout mirrors the esp32 CLOSE_EVT safety net. +static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; + +// HCI "connection timeout" reason, reported when a teardown had to be forced. +static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; + +// Initiating-scan parameters and connection-event lengths for outgoing +// connections (BTstack-specific knobs; the connection intervals themselves are +// the shared FAST/MEDIUM parameters from ble_device_base/ble_client_state.h, +// used in the same lifecycle places as esp32: FAST for connect and service +// discovery, MEDIUM once established). +static constexpr uint16_t CONN_SCAN_INTERVAL = 96; // 60 ms in 0.625 ms units +static constexpr uint16_t CONN_SCAN_WINDOW = 48; // 30 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MIN = 16; // 10 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MAX = 48; // 30 ms in 0.625 ms units + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; +uint8_t RP2GattClient::instance_count = 0; +btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { + if (uuid16 != 0) { + return ESPBTUUID::from_uint16(uuid16); + } + // BTstack structs carry the 128-bit form big-endian (printable order). + return ESPBTUUID::from_raw_reversed(uuid128); +} + +void RP2GattClient::setup() { + // Pre-create every pool entry so the packet handlers' allocate() calls are + // always a free-list pop -- the IRQ path must never reach malloc(). + if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { + ESP_LOGE(TAG, "GATT event pool warm-up failed"); + this->mark_failed(); + return; + } + + // Register this engine for IRQ-context event routing. + if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { + // Cannot happen with codegen-sized storage; refuse loudly if it ever does. + ESP_LOGE(TAG, "GATT client registry full"); + this->mark_failed(); + return; + } + { + // One locked section: the slot store lands before the count bump, and a + // live HCI handler (N > 1 builds) cannot read a half-written registry. + BluetoothLock lock; + instances[instance_count] = this; + instance_count++; + // One HCI event handler for all engine instances (BTstack supports + // multiple registrations, so rp2040_ble's own handler is unaffected). + if (hci_event_registration.callback == nullptr) { + hci_event_registration.callback = &RP2GattClient::hci_packet_handler; + hci_add_event_handler(&hci_event_registration); + } + } + + this->disable_loop(); +} + +float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } + +void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } + +// ---- IRQ-context handlers: copy-and-enqueue only ---- + +RP2GattClient *RP2GattClient::instance_for_con_handle(hci_con_handle_t con_handle) { + for (uint8_t i = 0; i < instance_count; i++) { + if (instances[i]->con_handle_ == con_handle) { + return instances[i]; + } + } + return nullptr; +} + +void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + switch (event_type) { + case HCI_EVENT_META_GAP: { + if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { + break; + } + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + uint8_t status = gap_subevent_le_connection_complete_get_status(packet); + hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); + // Route to the engine that is waiting for this peer. + for (uint8_t i = 0; i < instance_count; i++) { + RP2GattClient *inst = instances[i]; + if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) { + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); + break; + } + } + break; + } + case HCI_EVENT_DISCONNECTION_COMPLETE: { + hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet); + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst == nullptr && instance_count == 1) { + // The main loop may not have recorded the handle yet (the CONNECTED + // event is still queued); with a single engine the connecting + // instance is unambiguous, so route there to close the + // accept-then-drop window. With multiple engines the event has no + // address to match on, so it must be dropped instead of guessed. + RP2GattClient *candidate = instances[0]; + if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) { + inst = candidate; + } + } + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + // Every GATT event carries the connection handle in the same position via + // its accessor; route on it. + hci_con_handle_t con_handle; + switch (event_type) { + case GATT_EVENT_MTU: + con_handle = gatt_event_mtu_get_handle(packet); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: + con_handle = gatt_event_service_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: + con_handle = gatt_event_characteristic_query_result_get_handle(packet); + break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: + con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_characteristic_value_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_characteristic_descriptor_query_result_get_handle(packet); + break; + case GATT_EVENT_NOTIFICATION: + con_handle = gatt_event_notification_get_handle(packet); + break; + case GATT_EVENT_INDICATION: + con_handle = gatt_event_indication_get_handle(packet); + break; + case GATT_EVENT_QUERY_COMPLETE: + con_handle = gatt_event_query_complete_get_handle(packet); + break; + default: + return; + } + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst != nullptr) { + inst->handle_gatt_event_irq_(event_type, packet); + } +} + +void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet) { + switch (event_type) { + case GATT_EVENT_MTU: + this->enqueue_event_irq_(RP2GattEvent::MTU_EXCHANGED, 0, gatt_event_mtu_get_MTU(packet)); + break; + case GATT_EVENT_QUERY_COMPLETE: + this->enqueue_event_irq_(RP2GattEvent::QUERY_COMPLETE, gatt_event_query_complete_get_att_status(packet), 0); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->service_count_ >= RP2_GATT_MAX_SERVICES) { + this->truncated_ = true; + break; + } + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + auto &dst = this->arena_->services[this->service_count_]; + dst.uuid = uuid_from_btstack(service.uuid16, service.uuid128); + dst.start_handle = service.start_group_handle; + dst.end_handle = service.end_group_handle; + dst.first_characteristic = 0; + dst.characteristic_count = 0; + this->service_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->char_count_ >= RP2_GATT_MAX_CHARACTERISTICS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + auto &dst = this->arena_->characteristics[this->char_count_]; + dst.uuid = uuid_from_btstack(characteristic.uuid16, characteristic.uuid128); + dst.value_handle = characteristic.value_handle; + dst.end_handle = characteristic.end_handle; + dst.properties = static_cast(characteristic.properties); + dst.first_descriptor = 0; + dst.descriptor_count = 0; + this->char_count_++; + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->desc_count_ >= RP2_GATT_MAX_DESCRIPTORS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + auto &dst = this->arena_->descriptors[this->desc_count_]; + dst.uuid = uuid_from_btstack(descriptor.uuid16, descriptor.uuid128); + dst.handle = descriptor.handle; + this->desc_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: { + uint16_t len = gatt_event_characteristic_value_query_result_get_value_length(packet); + if (len > RP2_GATT_MAX_ATTR_LEN) { + len = RP2_GATT_MAX_ATTR_LEN; + } + memcpy(this->op_buffer_, gatt_event_characteristic_value_query_result_get_value(packet), len); + this->op_len_ = len; + break; + } + case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: { + uint16_t len = gatt_event_characteristic_descriptor_query_result_get_descriptor_length(packet); + if (len > RP2_GATT_MAX_ATTR_LEN) { + len = RP2_GATT_MAX_ATTR_LEN; + } + memcpy(this->op_buffer_, gatt_event_characteristic_descriptor_query_result_get_descriptor(packet), len); + this->op_len_ = len; + break; + } + case GATT_EVENT_NOTIFICATION: + this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), + gatt_event_notification_get_value(packet), + gatt_event_notification_get_value_length(packet)); + break; + case GATT_EVENT_INDICATION: + // BTstack auto-confirms indications; deliver like a notification. + this->enqueue_notify_irq_(gatt_event_indication_get_value_handle(packet), gatt_event_indication_get_value(packet), + gatt_event_indication_get_value_length(packet)); + break; + default: + break; + } +} + +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { + RP2GattEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + this->event_queue_.increment_dropped_count(); + return; + } + event->type = type; + event->status = status; + event->value = value; + this->event_queue_.push(event); +} + +void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { + RP2GattNotifyEvent *event = this->notify_pool_.allocate(); + if (event == nullptr) { + this->notify_queue_.increment_dropped_count(); + return; + } + event->handle = handle; + event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; + memcpy(event->data, data, event->len); + this->notify_queue_.push(event); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +// ---- Main-loop state machine ---- + +void RP2GattClient::loop() { + RP2GattEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + RP2GattEvent copy = *event; + this->event_pool_.release(event); + this->handle_event_(copy); + } + + RP2GattNotifyEvent *notify; + while ((notify = this->notify_queue_.pop()) != nullptr) { + if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + this->listener_->on_notify_data(notify->handle, notify->data, notify->len); + } + this->notify_pool_.release(notify); + } + + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + // Control events must not be lost; the connection state is no longer + // trustworthy — recover with a forced teardown. + ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); + this->disconnect(); + } + uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); + if (notify_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped); + } + + if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connect timeout"); + if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) { + if (!this->connect_cancel_attempted_) { + this->connect_cancel_attempted_ = true; + BluetoothLock lock; + gap_connect_cancel(); + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer + // so a lost event escalates below instead of wedging here. + this->connect_started_ = now; + } else { + // The cancel's completion never arrived: reclaim the slot and the + // scan rather than cancelling forever. + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else { + // The link is up (MTU exchange stalled): tear it down properly so the + // controller frees its side; the DISCONNECTING safety net below + // reclaims state if the disconnection event is lost. Dropping engine + // state without gap_disconnect would leak the live link and the + // single GATT slot for the rest of the boot. + this->disconnect(); + } + } + } else if (this->state_ == EngineState::DISCONNECTING) { + if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else if (this->state_ == EngineState::IDLE) { + this->disable_loop(); + } +} + +void RP2GattClient::handle_event_(const RP2GattEvent &event) { + switch (event.type) { + case RP2GattEvent::CONNECTED: + this->handle_connected_(event.status, event.value); + break; + case RP2GattEvent::DISCONNECTED: + this->handle_disconnected_(event.status); + break; + case RP2GattEvent::MTU_EXCHANGED: + if (this->state_ == EngineState::MTU_EXCHANGE) { + this->mtu_ = event.value; + ESP_LOGD(TAG, "MTU %u", this->mtu_); + this->state_ = EngineState::READY; + // Scanning resumes and runs alongside the established connection. + this->release_scan_inhibit_(); + if (this->listener_ != nullptr) { + this->listener_->on_connection_state(true, this->mtu_, 0); + } + } + break; + case RP2GattEvent::QUERY_COMPLETE: + this->handle_query_complete_(event.status); + break; + } +} + +void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { + if (this->state_ != EngineState::CONNECTING) { + return; + } + if (status != 0) { + ESP_LOGW(TAG, "Connect failed, status=0x%02x", status); + this->fail_connection_(status); + return; + } + if (this->cancel_requested_) { + // A disconnect request raced the connection complete and lost; finish + // the teardown instead of reporting a connection nobody wants. + this->con_handle_ = con_handle; + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + uint8_t disc_status; + { + BluetoothLock lock; + disc_status = gap_disconnect(this->con_handle_); + } + if (disc_status != 0) { + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + return; + } + this->con_handle_ = con_handle; + this->state_ = EngineState::MTU_EXCHANGE; + ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle); + BluetoothLock lock; + // One wildcard listener covers notifications/indications for every + // characteristic on this connection; the CCCD writes come from the API + // client as plain descriptor writes. + gatt_client_listen_for_characteristic_value_updates(&this->notification_registration_, + &RP2GattClient::gatt_packet_handler, this->con_handle_, nullptr); + // Auto MTU negotiation is disabled (see rp2040_ble enable hooks), so the + // exchange is kicked explicitly; GATT_EVENT_MTU completes it. Without the + // explicit kick the MTU would only be exchanged on the first GATT query, + // which never happens on a V3_WITH_CACHE connection. + // Both registration calls above return void (BTstack 075a078, arduino-pico + // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // the connect timeout in loop(). + gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); +} + +void RP2GattClient::release_scan_inhibit_() { + if (this->holds_scan_inhibit_) { + this->holds_scan_inhibit_ = false; + this->parent_->release_scan_inhibit(); + } +} + +void RP2GattClient::fail_connection_(uint8_t reason) { + this->cleanup_link_state_(); + this->release_scan_inhibit_(); + this->state_ = EngineState::IDLE; + if (this->listener_ != nullptr) { + this->listener_->on_connection_state(false, 0, reason); + } +} + +void RP2GattClient::cleanup_link_state_() { + // Drop notifications queued behind the disconnect so they cannot emit + // against a freed slot (address 0) on the next loop. + RP2GattNotifyEvent *stale; + while ((stale = this->notify_queue_.pop()) != nullptr) { + this->notify_pool_.release(stale); + } + // The wildcard listener is registered on the normal connect path right + // after con_handle_ is recorded; the cancel branch tears down before + // registering, where stop_listening on an unregistered entry is a no-op. + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + BluetoothLock lock; + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; + this->notify_subscription_count_ = 0; + this->cancel_requested_ = false; + this->op_type_ = OpType::NONE; + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); +} + +void RP2GattClient::handle_disconnected_(uint8_t reason) { + if (this->state_ == EngineState::IDLE) { + return; + } + ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason); + this->fail_connection_(reason); +} + +void RP2GattClient::handle_query_complete_(uint8_t att_status) { + // Stale completions cannot cross connections: the loop drains the whole + // event queue every iteration, teardown resets op/discovery state, and a + // new discovery is only issued after the new link's MTU event — which in + // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate + // from the query state machine). Completions with nothing in flight are + // dropped below. + if (this->op_type_ != OpType::NONE) { + OpType op = this->op_type_; + this->op_type_ = OpType::NONE; + if (this->listener_ == nullptr) { + return; + } + switch (op) { + case OpType::READ_CHAR: + case OpType::READ_DESC: + this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, + att_status); + break; + case OpType::WRITE_CHAR: + case OpType::WRITE_DESC: + this->listener_->on_write_result(this->op_handle_, att_status); + break; + default: + break; + } + return; + } + if (this->discovery_phase_ != DiscoveryPhase::NONE) { + this->advance_discovery_(att_status); + } +} + +// ---- Service discovery ---- + +int RP2GattClient::discover_services() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (this->arena_ == nullptr) { + // Transient: freed in release_services() right after the table streams + // to the API client (mirrors Bluedroid's own per-connection GATT DB + // lifetime on esp32). Checked: a fragmented heap must surface as a + // stack error the proxy can report, not a device reset. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_ = allocator.allocate(1); + if (this->arena_ == nullptr) { + ESP_LOGE(TAG, "Service table allocation failed"); + return ble_device_base::GATT_ERR_NO_MEMORY; + } + new (this->arena_) ServiceArena(); + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; + this->discovery_phase_ = DiscoveryPhase::SERVICES; + BluetoothLock lock; + uint8_t status = gatt_client_discover_primary_services(&RP2GattClient::gatt_packet_handler, this->con_handle_); + if (status != 0) { + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); + return status; + } + return 0; +} + +int RP2GattClient::issue_characteristic_query_(uint16_t service_index) { + auto &service = this->arena_->services[service_index]; + gatt_client_service_t btstack_service = {}; + btstack_service.start_group_handle = service.start_handle; + btstack_service.end_group_handle = service.end_handle; + service.first_characteristic = this->char_count_; + BluetoothLock lock; + return gatt_client_discover_characteristics_for_service(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_service); +} + +int RP2GattClient::issue_descriptor_query_(uint16_t char_index) { + auto &chr = this->arena_->characteristics[char_index]; + gatt_client_characteristic_t btstack_characteristic = {}; + btstack_characteristic.value_handle = chr.value_handle; + btstack_characteristic.end_handle = chr.end_handle; + chr.first_descriptor = this->desc_count_; + BluetoothLock lock; + return gatt_client_discover_characteristic_descriptors(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_characteristic); +} + +void RP2GattClient::advance_discovery_(uint8_t att_status) { + if (this->arena_ == nullptr) { + // release_services() is publicly callable; a table freed mid-discovery + // must end the discovery instead of dereferencing a null arena. + this->finish_discovery_(GATT_ERR_NOT_CONNECTED); + return; + } + if (att_status != 0) { + this->finish_discovery_(att_status); + return; + } + switch (this->discovery_phase_) { + case DiscoveryPhase::SERVICES: + if (this->service_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::CHARACTERISTICS; + this->disc_service_cursor_ = 0; + if (int err = this->issue_characteristic_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + case DiscoveryPhase::CHARACTERISTICS: { + auto &service = this->arena_->services[this->disc_service_cursor_]; + service.characteristic_count = this->char_count_ - service.first_characteristic; + this->disc_service_cursor_++; + if (this->disc_service_cursor_ < this->service_count_) { + if (int err = this->issue_characteristic_query_(this->disc_service_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + if (this->char_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::DESCRIPTORS; + this->disc_char_cursor_ = 0; + if (int err = this->issue_descriptor_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + } + case DiscoveryPhase::DESCRIPTORS: { + auto &chr = this->arena_->characteristics[this->disc_char_cursor_]; + chr.descriptor_count = this->desc_count_ - chr.first_descriptor; + this->disc_char_cursor_++; + if (this->disc_char_cursor_ < this->char_count_) { + if (int err = this->issue_descriptor_query_(this->disc_char_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + this->finish_discovery_(0); + break; + } + default: + break; + } +} + +void RP2GattClient::finish_discovery_(int error) { + this->discovery_phase_ = DiscoveryPhase::NONE; + ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_, + this->char_count_, this->desc_count_); + if (error == 0 && this->truncated_) { + // A partial table must not stream: V3 clients cache the database + // permanently, so an incomplete one would be wrong forever. + error = ATT_ERROR_INSUFFICIENT_RESOURCES; + } + if (error == 0) { + // Discovery no longer needs the fast interval; settle into the shared + // steady-state parameters (same lifecycle place as esp32). Status + // discarded: BTstack fails this only for an already-gone handle. + BluetoothLock lock; + gap_update_connection_parameters(this->con_handle_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, + MEDIUM_CONN_TIMEOUT); + } + if (this->truncated_) { + ESP_LOGE(TAG, "Service table truncated (device exceeds %u services / %u characteristics / %u descriptors)", + RP2_GATT_MAX_SERVICES, RP2_GATT_MAX_CHARACTERISTICS, RP2_GATT_MAX_DESCRIPTORS); + } + if (error != 0) { + this->release_services(); + } + if (this->listener_ != nullptr) { + this->listener_->on_service_discovery_done(error); + } +} + +ble_device_base::GattServiceTable RP2GattClient::get_service_table() { + ble_device_base::GattServiceTable table; + if (this->arena_ != nullptr) { + table.services = this->arena_->services; + table.characteristics = this->arena_->characteristics; + table.descriptors = this->arena_->descriptors; + table.service_count = this->service_count_; + table.characteristic_count = this->char_count_; + table.descriptor_count = this->desc_count_; + } + return table; +} + +void RP2GattClient::release_services() { + if (this->arena_ != nullptr) { + // Under BluetoothLock so a discovery result landing in the BTstack + // context cannot write into the arena mid-free. + BluetoothLock lock; + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_->~ServiceArena(); + allocator.deallocate(this->arena_, 1); + this->arena_ = nullptr; + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; +} + +// ---- Connection control ---- + +int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->is_failed()) { + // setup() failed: nothing is registered for event routing and loop() + // never runs, so a connect could not complete or time out. + return GATT_ERR_NOT_CONNECTED; + } + if (this->state_ != EngineState::IDLE) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (!this->parent_->is_active()) { + return GATT_ERR_NOT_CONNECTED; + } + ble_device_base::uint64_to_mac_msb_first(address, this->peer_addr_); + // BLE_ADDR_TYPE_* code space: bit 0 distinguishes public from random + // (resolved RPA types 2/3 connect with the underlying kind). + this->peer_addr_type_ = (addr_type & 1) != 0 ? BD_ADDR_TYPE_LE_RANDOM : BD_ADDR_TYPE_LE_PUBLIC; + + // Stop the shared radio's scan for the duration of the connect attempt + // (esp32 parity: initiating and scanning contend for the radio). + this->holds_scan_inhibit_ = true; + this->parent_->inhibit_scan(); + this->connect_cancel_attempted_ = false; + this->cancel_requested_ = false; + uint8_t status; + { + BluetoothLock lock; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, + 0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + } + if (status != 0) { + ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status); + this->release_scan_inhibit_(); + return status; + } + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + this->enable_loop(); + return 0; +} + +int RP2GattClient::disconnect() { + switch (this->state_) { + case EngineState::IDLE: + return GATT_ERR_NOT_CONNECTED; + case EngineState::DISCONNECTING: + return 0; // already on its way down + case EngineState::CONNECTING: { + if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { + // The cancel can lose the race against a successful connection + // complete; handle_connected_ checks this flag and finishes the + // teardown instead of proceeding. It also counts as the one cancel + // attempt, so a lost completion escalates on the next timeout tick. + this->cancel_requested_ = true; + this->connect_cancel_attempted_ = true; + BluetoothLock lock; + gap_connect_cancel(); + // Completion arrives as a failed connection-complete event. + return 0; + } + break; + } + default: + break; + } + uint8_t status; + { + BluetoothLock lock; + status = gap_disconnect(this->con_handle_); + } + if (status != 0) { + // Refused (handle already gone): complete via the event queue so the + // listener cannot re-enter disconnect() mid-call. BluetoothLock stops + // the IRQ producer, so this main-loop push is SPSC-safe. + ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status); + { + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + } + } + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + this->enable_loop(); + return 0; +} + +// ---- GATT operations (single outstanding op) ---- + +int RP2GattClient::read_characteristic(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_CHAR; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + if (!response) { + // Synchronous in BTstack: the data is copied into the L2CAP buffer before + // the call returns, and no completion event exists — synthesize one so + // the wire behavior matches esp32 (which reports write-no-response too). + uint8_t status; + { + BluetoothLock lock; + status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, + const_cast(data)); + } + if (status == 0 && this->listener_ != nullptr) { + this->listener_->on_write_result(handle, 0); + } + return status; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + // BTstack keeps the caller's pointer until the request is sent; the payload + // must live in engine-owned storage across the async operation. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status; + if (len <= this->mtu_ - 3) { + status = gatt_client_write_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, handle, + len, this->op_buffer_); + } else { + status = gatt_client_write_long_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, + handle, len, this->op_buffer_); + } + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::read_descriptor(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_DESC; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_DESC; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status = gatt_client_write_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle, len, this->op_buffer_); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + // The CCCD write arrives separately as a descriptor write (V3 semantics); + // this call only gates local delivery via the subscription list. + if (enable) { + if (!this->notify_subscribed_(handle)) { + if (this->notify_subscription_count_ >= RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS) { + return GATT_ERR_NO_MEMORY; + } + this->notify_subscriptions_[this->notify_subscription_count_++] = handle; + } + } else { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + this->notify_subscriptions_[i] = this->notify_subscriptions_[--this->notify_subscription_count_]; + break; + } + } + } + if (this->listener_ != nullptr) { + this->listener_->on_notify_state(handle, enable, 0); + } + return 0; +} + +bool RP2GattClient::notify_subscribed_(uint16_t handle) const { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + return true; + } + } + return false; +} + +int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h new file mode 100644 index 0000000000..145508bdf6 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -0,0 +1,206 @@ +// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. +// +// Implements ble_device_base::BLEGattConnection for the hub BluetoothConnection +// wrapper. BTstack packet handlers run in the CYW43 async-context low-priority +// IRQ (or on the main-loop stack during BluetoothLock release), so handlers +// only copy into per-instance lock-free queues/storage; loop() drains them and +// drives the state machine. Every BTstack call issued from the main loop is +// wrapped in BluetoothLock. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +// Caps for the transient service table. Sized generously for real devices +// (typical peripherals expose < 8 services / < 30 characteristics); a peer +// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than +// streaming an incomplete database a V3 client would cache permanently. +static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16; +static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96; +static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96; + +// Concurrent notify subscriptions per connection (enable fails with +// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful). +static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16; + +// ATT spec maximum attribute value length; bounds the op buffer and +// notification payloads. +static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; + +// Control events from the BTstack handlers to loop(). +struct RP2GattEvent { + enum Type : uint8_t { + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + }; + Type type; + uint8_t status; + uint16_t value; + void release() {} +}; + +// One notification/indication from the peer. +struct RP2GattNotifyEvent { + uint16_t handle; + uint16_t len; + uint8_t data[RP2_GATT_MAX_ATTR_LEN]; + void release() {} +}; + +static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; +// Depth 4: the queue is drained every main-loop iteration and each slot is a +// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. +static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; + +class RP2GattClient final : public Component, + public ble_device_base::BLEGattConnection, + public Parented { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + // ---- ble_device_base::BLEGattConnection ---- + int connect(uint64_t address, uint8_t addr_type) override; + int disconnect() override; + int discover_services() override; + int read_characteristic(uint16_t handle) override; + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override; + int read_descriptor(uint16_t handle) override; + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; + int notify_characteristic(uint16_t handle, bool enable) override; + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) override; + ble_device_base::GattServiceTable get_service_table() override; + void release_services() override; + + protected: + // Link/engine state. Discovery and GATT ops have their own cursors below — + // the link stays READY while they run. + enum class EngineState : uint8_t { + IDLE, + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered + DISCONNECTING, + }; + + enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; + + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, READ_DESC, WRITE_DESC }; + + // The whole table in one transient allocation (RAMAllocator, checked), + // freed after streaming. + struct ServiceArena { + ble_device_base::GattService services[RP2_GATT_MAX_SERVICES]; + ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS]; + ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS]; + }; + + // BTstack packet handlers (IRQ context: copy-and-enqueue only). + static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); + + void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); + void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); + void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + + // Main-loop state machine. + void handle_event_(const RP2GattEvent &event); + void handle_connected_(uint8_t status, uint16_t con_handle); + void handle_disconnected_(uint8_t reason); + void handle_query_complete_(uint8_t att_status); + void advance_discovery_(uint8_t att_status); + int issue_characteristic_query_(uint16_t service_index); + int issue_descriptor_query_(uint16_t char_index); + void finish_discovery_(int error); + void fail_connection_(uint8_t reason); + void cleanup_link_state_(); + bool notify_subscribed_(uint16_t handle) const; + void release_scan_inhibit_(); + bool op_in_flight_() const { + return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; + } + + // Group 1: containers / large storage + ServiceArena *arena_{nullptr}; + esphome::LockFreeQueue event_queue_; + esphome::EventPool event_pool_; + esphome::LockFreeQueue notify_queue_; + esphome::EventPool notify_pool_; + + // Shared buffer for the single outstanding GATT op: write payloads (BTstack + // keeps the caller's pointer until the request is sent) and read results + // (written from the handler, read after QUERY_COMPLETE is drained). + uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN]; + + // BTstack registrations + gatt_client_notification_t notification_registration_{}; + + // Group 3: 4-byte types + uint32_t connect_started_{0}; + uint32_t disconnecting_started_{0}; + + // Group 4: 2-byte types (table counters written from the handler during + // discovery, read from the main loop after the phase's QUERY_COMPLETE) + hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID}; + uint16_t mtu_{23}; + uint16_t op_handle_{0}; + uint16_t op_len_{0}; + uint16_t service_count_{0}; + uint16_t char_count_{0}; + uint16_t desc_count_{0}; + uint16_t disc_service_cursor_{0}; + uint16_t disc_char_cursor_{0}; + + // Group 5: arrays / 1-byte types + // Subscribed notify handles; the loop() drain filters the wildcard + // listener's deliveries on this list (esp32 parity for enable=false). + std::array notify_subscriptions_{}; + uint8_t notify_subscription_count_{0}; + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + EngineState state_{EngineState::IDLE}; + DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; + OpType op_type_{OpType::NONE}; + bool truncated_{false}; + // One cancel attempt per connect: the second timeout escalates to failure. + bool connect_cancel_attempted_{false}; + // A disconnect request raced an in-flight connect; finish teardown on link-up. + bool cancel_requested_{false}; + // This engine's own hold on the shared scan inhibit, so the pairing stays + // one-to-one per connection even with multiple slots. + bool holds_scan_inhibit_{false}; + + // Instance registry for routing BTstack events (IRQ context) to engines. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT]; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static uint8_t instance_count; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t hci_event_registration; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a0706ae4ae..ed6dbfe557 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -48,7 +48,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). # Coupled to bluetooth_connection: platforms with a GATT backend are also -# listed in its FILTER_SOURCE_FILES hub entry. +# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index bd80f71a49..e6cdde9cda 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,20 +13,14 @@ namespace esphome::esp32_ble_client { 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 constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms -static constexpr 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. -static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s - -// Fastest connection parameters for devices with short discovery timeouts -static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) -static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms -static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +// Connection parameters are shared with the other GATT client backends +// (ble_device_base/ble_client_state.h) so the platforms cannot drift. +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 70ececb528..8106763489 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -63,8 +63,14 @@ class RP2BLETracker : public Component, // BTstack delivers scan responses as separate advertisement reports rather // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). No GATT path yet. - return {.active_scan = true, .merges_scan_response = false, .gatt = false, .scan_mode_switch = true}; + // merges per address (Home Assistant does). GATT is available when the + // BTstack connection backend is compiled in (bluetooth_proxy active). +#ifdef USE_BLE_GATT_CLIENT + constexpr bool has_gatt = true; +#else + constexpr bool has_gatt = false; +#endif + return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. From 715b14aebab1aff8bf687708e6d95bdf9d607238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 20:59:27 +0300 Subject: [PATCH 012/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 3: mopeka_ble, mopeka_pro_check, mopeka_std_check) (#17951) Co-authored-by: J. Nick Koston --- esphome/components/mopeka_ble/__init__.py | 23 +++++++++++-------- esphome/components/mopeka_ble/mopeka_ble.cpp | 14 ++++------- esphome/components/mopeka_ble/mopeka_ble.h | 10 +++----- .../mopeka_pro_check/mopeka_pro_check.cpp | 6 +---- .../mopeka_pro_check/mopeka_pro_check.h | 10 +++----- esphome/components/mopeka_pro_check/sensor.py | 13 ++++++----- .../mopeka_std_check/mopeka_std_check.cpp | 8 ++----- .../mopeka_std_check/mopeka_std_check.h | 10 +++----- esphome/components/mopeka_std_check/sensor.py | 13 ++++++----- tests/components/mopeka_ble/common-ln.yaml | 1 + tests/components/mopeka_ble/common.yaml | 3 +++ .../mopeka_ble/test.ln882x-ard.yaml | 3 +++ .../mopeka_ble/validate.bk72xx-ard.yaml | 8 +++++++ .../mopeka_pro_check/common-ln.yaml | 8 +++++++ tests/components/mopeka_pro_check/common.yaml | 3 +++ .../mopeka_pro_check/test.ln882x-ard.yaml | 3 +++ .../mopeka_pro_check/validate.bk72xx-ard.yaml | 13 +++++++++++ .../mopeka_std_check/common-ln.yaml | 8 +++++++ tests/components/mopeka_std_check/common.yaml | 3 +++ .../mopeka_std_check/test.ln882x-ard.yaml | 3 +++ .../mopeka_std_check/validate.bk72xx-ard.yaml | 19 +++++++++++++++ 21 files changed, 119 insertions(+), 63 deletions(-) create mode 100644 tests/components/mopeka_ble/common-ln.yaml create mode 100644 tests/components/mopeka_ble/test.ln882x-ard.yaml create mode 100644 tests/components/mopeka_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/mopeka_pro_check/common-ln.yaml create mode 100644 tests/components/mopeka_pro_check/test.ln882x-ard.yaml create mode 100644 tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml create mode 100644 tests/components/mopeka_std_check/common-ln.yaml create mode 100644 tests/components/mopeka_std_check/test.ln882x-ard.yaml create mode 100644 tests/components/mopeka_std_check/validate.bk72xx-ard.yaml diff --git a/esphome/components/mopeka_ble/__init__.py b/esphome/components/mopeka_ble/__init__.py index c8648cbc63..ab261142b8 100644 --- a/esphome/components/mopeka_ble/__init__.py +++ b/esphome/components/mopeka_ble/__init__.py @@ -1,24 +1,27 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID CODEOWNERS = ["@spbrogan", "@Fabian-Schmidt"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CONF_SHOW_SENSORS_WITHOUT_SYNC = "show_sensors_without_sync" mopeka_ble_ns = cg.esphome_ns.namespace("mopeka_ble") MopekaListener = mopeka_ble_ns.class_( - "MopekaListener", esp32_ble_tracker.ESPBTDeviceListener + "MopekaListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(MopekaListener), - cv.Optional(CONF_SHOW_SENSORS_WITHOUT_SYNC, default=False): cv.boolean, - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MopekaListener), + cv.Optional(CONF_SHOW_SENSORS_WITHOUT_SYNC, default=False): cv.boolean, + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): @@ -27,4 +30,4 @@ async def to_code(config): cg.add( var.set_show_sensors_without_sync(config[CONF_SHOW_SENSORS_WITHOUT_SYNC]) ) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp index ff5dd8d61b..0bef1eb6d4 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.cpp +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -2,8 +2,6 @@ #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_ble { static const char *const TAG = "mopeka_ble"; @@ -34,7 +32,7 @@ static const uint8_t MANUFACTURER_NRF52_DATA_LENGTH = 10; * - Bluetooth data frame size */ -bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaListener::parse_device(const ble_device_base::ESPBTDevice &device) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; // Fetch information about BLE device. const auto &service_uuids = device.get_service_uuids(); @@ -50,8 +48,8 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) const auto &manu_data = manu_datas[0]; // Is the device maybe a Mopeka Std (CC2540) sensor. - if (service_uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID_CC2540)) { - if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_CC2540_ID)) { + if (service_uuid == ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID_CC2540)) { + if (manu_data.uuid != ble_device_base::ESPBTUUID::from_uint16(MANUFACTURER_CC2540_ID)) { return false; } @@ -66,8 +64,8 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } // Is the device maybe a Mopeka Pro (NRF52) sensor. - } else if (service_uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID_NRF52)) { - if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_NRF52_ID)) { + } else if (service_uuid == ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID_NRF52)) { + if (manu_data.uuid != ble_device_base::ESPBTUUID::from_uint16(MANUFACTURER_NRF52_ID)) { return false; } @@ -86,5 +84,3 @@ bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::mopeka_ble - -#endif diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index e6fae23aee..460668ae65 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -2,16 +2,14 @@ #include -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_ble { -class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { show_sensors_without_sync_ = show_sensors_without_sync; } @@ -21,5 +19,3 @@ class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { }; } // namespace esphome::mopeka_ble - -#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index ab0ff9a113..fe3178d3aa 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -1,8 +1,6 @@ #include "mopeka_pro_check.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_pro_check { static const char *const TAG = "mopeka_pro_check"; @@ -25,7 +23,7 @@ void MopekaProCheck::dump_config() { * Check if advertisement is for our sensor and if so decode it and * update the sensor state data. */ -bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaProCheck::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { return false; } @@ -154,5 +152,3 @@ SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector } } // namespace esphome::mopeka_pro_check - -#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index 40fb338350..0cd53107c6 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -5,9 +5,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::mopeka_pro_check { @@ -27,11 +25,11 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_min_signal_quality(SensorReadQuality min) { this->min_signal_quality_ = min; }; @@ -65,5 +63,3 @@ class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::mopeka_pro_check - -#endif diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 323175917d..0d10970550 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -56,11 +56,11 @@ CONF_SUPPORTED_TANKS_MAP = { } CODEOWNERS = ["@spbrogan"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] mopeka_pro_check_ns = cg.esphome_ns.namespace("mopeka_pro_check") MopekaProCheck = mopeka_pro_check_ns.class_( - "MopekaProCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "MopekaProCheck", ble_device_base.ESPBTDeviceListener, cg.Component ) SensorReadQuality = mopeka_pro_check_ns.enum("SensorReadQuality") @@ -71,7 +71,8 @@ SIGNAL_QUALITIES = { "HIGH": SensorReadQuality.QUALITY_HIGH, } -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_pro_check"), cv.Schema( { cv.GenerateID(): cv.declare_id(MopekaProCheck), @@ -122,15 +123,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index 519a45fcb5..d70306c97d 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -3,8 +3,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_std_check { static const char *const TAG = "mopeka_std_check"; @@ -33,7 +31,7 @@ void MopekaStdCheck::dump_config() { * Check if advertisement is for our sensor and if so decode it and * update the sensor state data. */ -bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { // Validate address. if (device.address_uint64() != this->address_) { return false; @@ -52,7 +50,7 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) return false; } const auto &service_uuid = service_uuids[0]; - if (service_uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(SERVICE_UUID)) { + if (service_uuid != ble_device_base::ESPBTUUID::from_uint16(SERVICE_UUID)) { return false; } } @@ -232,5 +230,3 @@ int8_t MopekaStdCheck::parse_temperature_(const mopeka_std_package *message) { } } // namespace esphome::mopeka_std_check - -#endif diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index 2f1681f6ea..75d9b36a58 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -3,12 +3,10 @@ #include #include -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::mopeka_std_check { enum SensorType { @@ -42,11 +40,11 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_level(sensor::Sensor *level) { this->level_ = level; }; @@ -74,5 +72,3 @@ class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::mopeka_std_check - -#endif diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index d4535d9671..5cc4ea3039 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -50,14 +50,15 @@ CONF_SUPPORTED_TANKS_MAP = { } CODEOWNERS = ["@Fabian-Schmidt"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] mopeka_std_check_ns = cg.esphome_ns.namespace("mopeka_std_check") MopekaStdCheck = mopeka_std_check_ns.class_( - "MopekaStdCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "MopekaStdCheck", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("mopeka_std_check"), cv.Schema( { cv.GenerateID(): cv.declare_id(MopekaStdCheck), @@ -93,15 +94,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/tests/components/mopeka_ble/common-ln.yaml b/tests/components/mopeka_ble/common-ln.yaml new file mode 100644 index 0000000000..14df729405 --- /dev/null +++ b/tests/components/mopeka_ble/common-ln.yaml @@ -0,0 +1 @@ +mopeka_ble: diff --git a/tests/components/mopeka_ble/common.yaml b/tests/components/mopeka_ble/common.yaml index a115404f1c..d511a449f9 100644 --- a/tests/components/mopeka_ble/common.yaml +++ b/tests/components/mopeka_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. mopeka_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/mopeka_ble/test.ln882x-ard.yaml b/tests/components/mopeka_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8026866234 --- /dev/null +++ b/tests/components/mopeka_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_ble: !include common-ln.yaml diff --git a/tests/components/mopeka_ble/validate.bk72xx-ard.yaml b/tests/components/mopeka_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..41fdf62d03 --- /dev/null +++ b/tests/components/mopeka_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +mopeka_ble: + ble_hub_id: ble_hub diff --git a/tests/components/mopeka_pro_check/common-ln.yaml b/tests/components/mopeka_pro_check/common-ln.yaml new file mode 100644 index 0000000000..1e28e1e58d --- /dev/null +++ b/tests/components/mopeka_pro_check/common-ln.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: mopeka_pro_check + mac_address: D3:75:F2:DC:16:91 + tank_type: 20LB_V + temperature: + name: Propane test temp + level: + name: Propane test level diff --git a/tests/components/mopeka_pro_check/common.yaml b/tests/components/mopeka_pro_check/common.yaml index 3533ecf631..15eabe1f25 100644 --- a/tests/components/mopeka_pro_check/common.yaml +++ b/tests/components/mopeka_pro_check/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: mopeka_pro_check + ble_hub_id: ble_tracker_hub mac_address: D3:75:F2:DC:16:91 tank_type: CUSTOM custom_distance_full: 40cm diff --git a/tests/components/mopeka_pro_check/test.ln882x-ard.yaml b/tests/components/mopeka_pro_check/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3484c7b5b3 --- /dev/null +++ b/tests/components/mopeka_pro_check/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_pro_check: !include common-ln.yaml diff --git a/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml b/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..888b879e07 --- /dev/null +++ b/tests/components/mopeka_pro_check/validate.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: mopeka_pro_check + ble_hub_id: ble_hub + mac_address: D3:75:F2:DC:16:91 + tank_type: 20lb_v + level: + name: BK Mopeka Pro Level diff --git a/tests/components/mopeka_std_check/common-ln.yaml b/tests/components/mopeka_std_check/common-ln.yaml new file mode 100644 index 0000000000..0b645dcaf3 --- /dev/null +++ b/tests/components/mopeka_std_check/common-ln.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: mopeka_std_check + mac_address: D3:75:F2:DC:16:91 + tank_type: Europe_11kg + temperature: + name: Propane test temp + level: + name: Propane test level diff --git a/tests/components/mopeka_std_check/common.yaml b/tests/components/mopeka_std_check/common.yaml index 383e2e2a19..e7224ba725 100644 --- a/tests/components/mopeka_std_check/common.yaml +++ b/tests/components/mopeka_std_check/common.yaml @@ -1,8 +1,11 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: # Example using 11kg 100% propane tank. + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: mopeka_std_check + ble_hub_id: ble_tracker_hub mac_address: D3:75:F2:DC:16:91 tank_type: Europe_11kg temperature: diff --git a/tests/components/mopeka_std_check/test.ln882x-ard.yaml b/tests/components/mopeka_std_check/test.ln882x-ard.yaml new file mode 100644 index 0000000000..11a54cb37c --- /dev/null +++ b/tests/components/mopeka_std_check/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + mopeka_std_check: !include common-ln.yaml diff --git a/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml b/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..86b4425518 --- /dev/null +++ b/tests/components/mopeka_std_check/validate.bk72xx-ard.yaml @@ -0,0 +1,19 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: mopeka_std_check + ble_hub_id: ble_hub + mac_address: D3:75:F2:DC:16:91 + tank_type: Europe_11kg + level: + name: BK Mopeka Std Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: mopeka_std_check + mac_address: D3:75:F2:DC:16:92 + tank_type: Europe_11kg + level: + name: BK Propane implicit level From 950cfc4da317b511f712b717a15ae49a9a2301d8 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Fri, 7 Aug 2026 21:33:37 +0300 Subject: [PATCH 013/597] [ld6002b] Add select and button platforms (4/5) (#17822) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/button/__init__.py | 101 ++++++++++ .../ld6002b/button/ld6002b_button.cpp | 7 + .../ld6002b/button/ld6002b_button.h | 18 ++ esphome/components/ld6002b/const.py | 11 + esphome/components/ld6002b/ld6002b.cpp | 190 ++++++++++++++++++ esphome/components/ld6002b/ld6002b.h | 43 +++- esphome/components/ld6002b/select/__init__.py | 60 ++++++ .../ld6002b/select/ld6002b_select.cpp | 10 + .../ld6002b/select/ld6002b_select.h | 18 ++ tests/component_tests/ld6002b/__init__.py | 0 .../ld6002b/test_final_validate.py | 82 ++++++++ tests/components/ld6002b/common.yaml | 32 +++ 12 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 esphome/components/ld6002b/button/__init__.py create mode 100644 esphome/components/ld6002b/button/ld6002b_button.cpp create mode 100644 esphome/components/ld6002b/button/ld6002b_button.h create mode 100644 esphome/components/ld6002b/select/__init__.py create mode 100644 esphome/components/ld6002b/select/ld6002b_select.cpp create mode 100644 esphome/components/ld6002b/select/ld6002b_select.h create mode 100644 tests/component_tests/ld6002b/__init__.py create mode 100644 tests/component_tests/ld6002b/test_final_validate.py diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py new file mode 100644 index 0000000000..0046131b62 --- /dev/null +++ b/esphome/components/ld6002b/button/__init__.py @@ -0,0 +1,101 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_WAKEUP_PIN, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, +) +import esphome.final_validate as fv + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_GET_DELAY, + CONF_GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME, + CONF_GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE, + CONF_LD6002B_ID, + CONF_RESET_UNATTENDED, + CONF_WAKE, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BButton = ld6002b_ns.class_("LD6002BButton", button.Button) +ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_GET_DELAY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_SENSITIVITY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_Z_RANGE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_INSTALLATION): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_RESET_UNATTENDED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_WAKE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +def final_validate(config): + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + if config.get(CONF_WAKE): + hub_path = full_config.get_path_for_id(hub_id) + hub_config = full_config.get_config_for_path(hub_path[:-1]) + if hub_config.get(CONF_WAKEUP_PIN) is None: + raise cv.Invalid( + f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component", + path=[CONF_WAKE], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + +BUTTON_MAP = { + CONF_GET_DELAY: ButtonType.GET_DELAY, + CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE, + CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME, + CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED, + CONF_WAKE: ButtonType.WAKE, +} + + +async def to_code(config): + for key, button_type in BUTTON_MAP.items(): + if button_config := config.get(key): + b = cg.new_Pvariable(button_config[CONF_ID], button_type) + await button.register_button(b, button_config) + await cg.register_parented(b, config[CONF_LD6002B_ID]) diff --git a/esphome/components/ld6002b/button/ld6002b_button.cpp b/esphome/components/ld6002b/button/ld6002b_button.cpp new file mode 100644 index 0000000000..fb398a9a59 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.cpp @@ -0,0 +1,7 @@ +#include "ld6002b_button.h" + +namespace esphome::ld6002b { + +void LD6002BButton::press_action() { this->parent_->press_button(this->type_); } + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/button/ld6002b_button.h b/esphome/components/ld6002b/button/ld6002b_button.h new file mode 100644 index 0000000000..c222143453 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BButton : public button::Button, public Parented { + public: + explicit LD6002BButton(ButtonType type) : type_(type) {} + + protected: + void press_action() override; + + ButtonType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index 9f9227988e..fac9f08015 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,14 +1,25 @@ CONF_AUTO_WAKE = "auto_wake" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_DELAY = "get_delay" +CONF_GET_INSTALLATION = "get_installation" +CONF_GET_LOW_POWER_MODE = "get_low_power_mode" +CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time" +CONF_GET_SENSITIVITY = "get_sensitivity" +CONF_GET_TRIGGER_SPEED = "get_trigger_speed" +CONF_GET_Z_RANGE = "get_z_range" CONF_HOLD_DELAY = "hold_delay" +CONF_INSTALLATION_MODE = "installation_mode" CONF_LD6002B_ID = "ld6002b_id" CONF_LOW_POWER = "low_power" CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" CONF_OTA_VERSION = "ota_version" CONF_POINT_CLOUD = "point_cloud" CONF_POINT_COUNT = "point_count" +CONF_RESET_UNATTENDED = "reset_unattended" CONF_TARGET_DISPLAY = "target_display" +CONF_TRIGGER_SPEED = "trigger_speed" +CONF_WAKE = "wake" CONF_WAKEUP_PULSE = "wakeup_pulse" CONF_WORK_MODE = "work_mode" CONF_Z = "z" diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 54979fe9eb..25b3da174c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -22,7 +22,10 @@ static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; +static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; @@ -34,11 +37,23 @@ static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A; +static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B; +static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; +static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D; +static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E; +static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F; +static constexpr uint32_t CMD_TRIGGER_FAST = 0x10; +static constexpr uint32_t CMD_GET_TRIGGER = 0x11; static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_INSTALL_TOP = 0x13; +static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; +static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; +static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id @@ -57,8 +72,30 @@ static const char *control_command_name(uint32_t command) { return "target_display_on"; case CMD_TARGET_DISPLAY_OFF: return "target_display_off"; + case CMD_SENSITIVITY_LOW: + return "sensitivity_low"; + case CMD_SENSITIVITY_MEDIUM: + return "sensitivity_medium"; + case CMD_SENSITIVITY_HIGH: + return "sensitivity_high"; + case CMD_GET_SENSITIVITY: + return "get_sensitivity"; + case CMD_TRIGGER_SLOW: + return "trigger_slow"; + case CMD_TRIGGER_MEDIUM: + return "trigger_medium"; + case CMD_TRIGGER_FAST: + return "trigger_fast"; + case CMD_GET_TRIGGER: + return "get_trigger"; case CMD_GET_Z_RANGE: return "get_z_range"; + case CMD_INSTALL_TOP: + return "install_top"; + case CMD_INSTALL_SIDE: + return "install_side"; + case CMD_GET_INSTALLATION: + return "get_installation"; case CMD_LOW_POWER_ON: return "low_power_on"; case CMD_LOW_POWER_OFF: @@ -67,6 +104,8 @@ static const char *control_command_name(uint32_t command) { return "get_low_power"; case CMD_GET_LOW_POWER_SLEEP: return "get_low_power_sleep"; + case CMD_RESET_UNATTENDED: + return "reset_unattended"; default: return "unknown"; } @@ -88,8 +127,14 @@ static const char *frame_type_name(uint16_t type) { return "report_point_cloud"; case TYPE_REPORT_DELAY: return "report_delay"; + case TYPE_REPORT_SENSITIVITY: + return "report_sensitivity"; + case TYPE_REPORT_TRIGGER: + return "report_trigger"; case TYPE_REPORT_Z_RANGE: return "report_z_range"; + case TYPE_REPORT_INSTALLATION: + return "report_installation"; case TYPE_REPORT_LOW_POWER: return "report_low_power"; case TYPE_REPORT_LOW_POWER_SLEEP: @@ -107,8 +152,14 @@ static bool is_expected_control_report(uint32_t command, uint16_t type) { switch (command) { case CMD_GET_DELAY: return type == TYPE_REPORT_DELAY; + case CMD_GET_SENSITIVITY: + return type == TYPE_REPORT_SENSITIVITY; + case CMD_GET_TRIGGER: + return type == TYPE_REPORT_TRIGGER; case CMD_GET_Z_RANGE: return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_INSTALLATION: + return type == TYPE_REPORT_INSTALLATION; case CMD_GET_LOW_POWER: case CMD_LOW_POWER_ON: case CMD_LOW_POWER_OFF: @@ -259,6 +310,18 @@ void LD6002BComponent::setup() { this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); this->point_cloud_enabled_ = want_point_cloud; } + +#ifdef USE_SELECT + if (this->sensitivity_select_ != nullptr) { + this->send_control_command_(CMD_GET_SENSITIVITY); + } + if (this->trigger_speed_select_ != nullptr) { + this->send_control_command_(CMD_GET_TRIGGER); + } + if (this->installation_select_ != nullptr) { + this->send_control_command_(CMD_GET_INSTALLATION); + } +#endif #ifdef USE_NUMBER if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { this->send_control_command_(CMD_GET_Z_RANGE); @@ -345,6 +408,11 @@ void LD6002BComponent::dump_config() { LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); LOG_SWITCH(" ", "Target Display", this->target_display_switch_); #endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); + LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); + LOG_SELECT(" ", "Installation Mode", this->installation_select_); +#endif } void LD6002BComponent::loop() { @@ -492,9 +560,18 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ case TYPE_REPORT_DELAY: this->handle_delay_report_(data, len); break; + case TYPE_REPORT_SENSITIVITY: + this->handle_sensitivity_report_(data, len); + break; + case TYPE_REPORT_TRIGGER: + this->handle_trigger_speed_report_(data, len); + break; case TYPE_REPORT_Z_RANGE: this->handle_z_range_report_(data, len); break; + case TYPE_REPORT_INSTALLATION: + this->handle_installation_report_(data, len); + break; case TYPE_REPORT_LOW_POWER: this->handle_low_power_report_(data, len); break; @@ -660,6 +737,32 @@ void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { #endif } +void LD6002BComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->sensitivity_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->sensitivity_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->trigger_speed_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->trigger_speed_select_->publish_state(value); + } +#endif +} + void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { if (len < 8) return; @@ -673,6 +776,19 @@ void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) #endif } +void LD6002BComponent::handle_installation_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->installation_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 1) { + this->installation_select_->publish_state(value); + } +#endif +} + void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; @@ -889,6 +1005,8 @@ void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data if (len > 0 && data != nullptr) { std::memcpy(this->wake_scratch_.data(), data, len); } + // A button pulse must not raise the pin in the middle of this one. + this->cancel_timeout(WAKE_BUTTON_TIMEOUT); this->wake_pulse_pending_ = true; this->wakeup_pin_->digital_write(false); const uint8_t generation = this->send_generation_; @@ -972,6 +1090,16 @@ void LD6002BComponent::send_z_range_() { this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +void LD6002BComponent::wake_() { + // A command's own pulse raises the pin and writes after it, so ride along instead of + // claiming the flag: claiming it would send that command down the immediate-write path + // with the pin still low. + if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) + return; + this->wakeup_pin_->digital_write(false); + this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); +} + void LD6002BComponent::set_number_value(NumberType type, float value) { switch (type) { case NumberType::HOLD_DELAY: { @@ -999,6 +1127,36 @@ void LD6002BComponent::set_number_value(NumberType type, float value) { } } +void LD6002BComponent::set_select_value(SelectType type, size_t index) { + switch (type) { + case SelectType::SENSITIVITY: + if (index == 0) { + this->send_control_command_(CMD_SENSITIVITY_LOW); + } else if (index == 1) { + this->send_control_command_(CMD_SENSITIVITY_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_SENSITIVITY_HIGH); + } + break; + case SelectType::TRIGGER_SPEED: + if (index == 0) { + this->send_control_command_(CMD_TRIGGER_SLOW); + } else if (index == 1) { + this->send_control_command_(CMD_TRIGGER_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_TRIGGER_FAST); + } + break; + case SelectType::INSTALLATION_MODE: + if (index == 0) { + this->send_control_command_(CMD_INSTALL_TOP); + } else if (index == 1) { + this->send_control_command_(CMD_INSTALL_SIDE); + } + break; + } +} + void LD6002BComponent::init_version_pref_() { #ifdef USE_TEXT_SENSOR if (this->ota_version_text_sensor_ == nullptr) { @@ -1124,4 +1282,36 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) { } } +void LD6002BComponent::press_button(ButtonType type) { + switch (type) { + case ButtonType::GET_DELAY: + this->send_control_command_(CMD_GET_DELAY); + break; + case ButtonType::GET_SENSITIVITY: + this->send_control_command_(CMD_GET_SENSITIVITY); + break; + case ButtonType::GET_TRIGGER_SPEED: + this->send_control_command_(CMD_GET_TRIGGER); + break; + case ButtonType::GET_Z_RANGE: + this->send_control_command_(CMD_GET_Z_RANGE); + break; + case ButtonType::GET_INSTALLATION: + this->send_control_command_(CMD_GET_INSTALLATION); + break; + case ButtonType::GET_LOW_POWER_MODE: + this->send_control_command_(CMD_GET_LOW_POWER); + break; + case ButtonType::GET_LOW_POWER_SLEEP_TIME: + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + break; + case ButtonType::RESET_UNATTENDED: + this->send_control_command_(CMD_RESET_UNATTENDED); + break; + case ButtonType::WAKE: + this->wake_(); + break; + } +} + } // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 5630d2d1a8..141f4ff027 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -3,6 +3,7 @@ #include "esphome/core/defines.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" #include "esphome/core/gpio.h" #include "esphome/components/uart/uart.h" #ifdef USE_SENSOR @@ -12,12 +13,14 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif #ifdef USE_TEXT_SENSOR -#include "esphome/core/preferences.h" #include "esphome/components/text_sensor/text_sensor.h" #endif #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif @@ -40,12 +43,30 @@ enum class NumberType : uint8_t { LOW_POWER_SLEEP, }; +enum class SelectType : uint8_t { + SENSITIVITY, + TRIGGER_SPEED, + INSTALLATION_MODE, +}; + enum class SwitchType : uint8_t { LOW_POWER, POINT_CLOUD, TARGET_DISPLAY, }; +enum class ButtonType : uint8_t { + GET_DELAY, + GET_SENSITIVITY, + GET_TRIGGER_SPEED, + GET_Z_RANGE, + GET_INSTALLATION, + GET_LOW_POWER_MODE, + GET_LOW_POWER_SLEEP_TIME, + RESET_UNATTENDED, + WAKE, +}; + #ifdef USE_SENSOR struct TargetSensors { sensor::Sensor *x{nullptr}; @@ -124,6 +145,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } #endif +#ifdef USE_SELECT + void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } + void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } + void set_installation_select(select::Select *select) { this->installation_select_ = select; } +#endif + #ifdef USE_SWITCH void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; } void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; } @@ -131,7 +158,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { #endif void set_number_value(NumberType type, float value); + void set_select_value(SelectType type, size_t index); void set_switch_state(SwitchType type, bool state); + void press_button(ButtonType type); protected: enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; @@ -148,7 +177,10 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void handle_target_report_(const uint8_t *data, uint16_t len); void handle_point_cloud_(const uint8_t *data, uint16_t len); void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_sensitivity_report_(const uint8_t *data, uint16_t len); + void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_installation_report_(const uint8_t *data, uint16_t len); void handle_low_power_report_(const uint8_t *data, uint16_t len); void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); void handle_work_mode_report_(const uint8_t *data, uint16_t len); @@ -173,6 +205,7 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void send_control_command_(uint32_t command); void send_z_range_(); + void wake_(); static uint16_t read_u16_be(const uint8_t *data); static uint32_t read_u32_le(const uint8_t *data); @@ -202,6 +235,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice { number::Number *z_max_number_{nullptr}; number::Number *low_power_sleep_number_{nullptr}; #endif +#ifdef USE_SELECT + select::Select *sensitivity_select_{nullptr}; + select::Select *trigger_speed_select_{nullptr}; + select::Select *installation_select_{nullptr}; +#endif #ifdef USE_SWITCH switch_::Switch *low_power_switch_{nullptr}; switch_::Switch *point_cloud_switch_{nullptr}; @@ -235,6 +273,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // How long the module stays awake after any frame, and so still answers the next one. static constexpr uint32_t MODULE_AWAKE_MS = 10000; static constexpr uint8_t CMD_MAX_RETRIES = 3; + // Named so a repeated press replaces its own pending timeout instead of stacking + // another, and so the command path can cancel it when it takes the pin over. + static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py new file mode 100644 index 0000000000..3fcc117e2f --- /dev/null +++ b/esphome/components/ld6002b/select/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG + +from .. import LD6002BComponent, ld6002b_ns +from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED + +DEPENDENCIES = ["ld6002b"] + +LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) +SelectType = ld6002b_ns.enum("SelectType", is_class=True) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_SENSITIVITY): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_TRIGGER_SPEED): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + } +) + + +SELECT_MAP = ( + ( + CONF_SENSITIVITY, + SelectType.SENSITIVITY, + "set_sensitivity_select", + ["low", "medium", "high"], + ), + ( + CONF_TRIGGER_SPEED, + SelectType.TRIGGER_SPEED, + "set_trigger_speed_select", + ["slow", "medium", "fast"], + ), + ( + CONF_INSTALLATION_MODE, + SelectType.INSTALLATION_MODE, + "set_installation_select", + ["top", "side"], + ), +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, select_type, setter, options in SELECT_MAP: + if conf := config.get(key): + s = await select.new_select(conf, select_type, options=options) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/select/ld6002b_select.cpp b/esphome/components/ld6002b/select/ld6002b_select.cpp new file mode 100644 index 0000000000..a6b524a665 --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_select.h" + +namespace esphome::ld6002b { + +void LD6002BSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_select_value(this->type_, index); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/select/ld6002b_select.h b/esphome/components/ld6002b/select/ld6002b_select.h new file mode 100644 index 0000000000..f380089a1e --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSelect : public select::Select, public Parented { + public: + explicit LD6002BSelect(SelectType type) : type_(type) {} + + protected: + void control(size_t index) override; + + SelectType type_; +}; + +} // namespace esphome::ld6002b diff --git a/tests/component_tests/ld6002b/__init__.py b/tests/component_tests/ld6002b/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py new file mode 100644 index 0000000000..49fa35eb13 --- /dev/null +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -0,0 +1,82 @@ +"""Tests for the wake button's wakeup_pin requirement in ld6002b.""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld6002b.button import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_WAKEUP_PIN, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld6002b_hub" + + +def _full_config(hub: ConfigType) -> Config: + """A full config carrying one ld6002b hub, as the ID pass leaves it. + + final_validate resolves the hub through get_path_for_id, so the declaring + path has to be registered the way validate_config registers it: the path of + the id value itself, whose parent is the hub's own config. + """ + full = Config() + full["ld6002b"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + return full + + +def _hub(*, wakeup_pin: bool) -> ConfigType: + hub: ConfigType = {CONF_ID: ID(HUB_ID, is_declaration=True, type="ld6002b")} + if wakeup_pin: + hub[CONF_WAKEUP_PIN] = {"number": 4} + return hub + + +def _buttons(**buttons: str) -> ConfigType: + """A button platform config naming the given buttons on the shared hub.""" + config: ConfigType = { + "ld6002b_id": ID(HUB_ID, is_declaration=False, type="ld6002b") + } + config.update({key: {"name": name} for key, name in buttons.items()}) + return config + + +def _validated(config: ConfigType) -> ConfigType: + """Run the button schema, then the final validation the hub is checked in.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_wake_without_wakeup_pin_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The wake button drives the pin directly, so a hub without one cannot serve it.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises(cv.Invalid, match="wake requires wakeup_pin"): + _validated(_buttons(wake="Wake")) + + +def test_wake_with_wakeup_pin_passes(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=True)) + ) + + _validated(_buttons(wake="Wake")) + + +def test_other_buttons_do_not_need_the_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """Only wake drives the pin; the query buttons stay usable without one.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + _validated(_buttons(get_delay="Get Delay")) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index f8a9e95340..e31af49aec 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -71,6 +71,16 @@ number: low_power_sleep_time: name: Low Power Sleep +select: + - platform: ld6002b + ld6002b_id: ld6002b_radar + sensitivity: + name: Sensitivity + trigger_speed: + name: Trigger Speed + installation_mode: + name: Installation + switch: - platform: ld6002b ld6002b_id: ld6002b_radar @@ -80,3 +90,25 @@ switch: name: Point Cloud target_display: name: Target Display + +button: + - platform: ld6002b + ld6002b_id: ld6002b_radar + get_delay: + name: Get Delay + get_sensitivity: + name: Get Sensitivity + get_trigger_speed: + name: Get Trigger Speed + get_z_range: + name: Get Z Range + get_installation: + name: Get Installation + get_low_power_mode: + name: Get Low Power Mode + get_low_power_sleep_time: + name: Get Low Power Sleep + reset_unattended: + name: Reset Unattended + wake: + name: Wake From 2e1c517821067632512ed45c00ba603f2c1a76a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 13:51:41 -0500 Subject: [PATCH 014/597] [bluetooth_proxy] Enable active connections on rp2 (#18132) --- .../components/bluetooth_proxy/__init__.py | 172 +++++++++++++++--- esphome/components/rp2040_ble/__init__.py | 2 + .../components/rp2_ble_tracker/__init__.py | 3 +- esphome/core/defines.h | 7 +- .../test_idf_max_connections_mirror.py | 10 +- .../test_outer_schema_mirror.py | 6 +- .../bluetooth_proxy/test_platform_gates.py | 100 +++++++++- .../bluetooth_connection/common.yaml | 8 + .../validate.rp2040-ard.yaml | 11 ++ .../test-passive.rp2040-ard.yaml | 11 ++ .../bluetooth_proxy/test.rp2040-ard.yaml | 4 +- 11 files changed, 287 insertions(+), 47 deletions(-) create mode 100644 tests/components/bluetooth_connection/common.yaml create mode 100644 tests/components/bluetooth_connection/validate.rp2040-ard.yaml create mode 100644 tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ed6dbfe557..057b15193a 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -59,15 +59,17 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" +CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component) -# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable -# CONFIG_SCHEMA below can state the connection_slots range without importing the -# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together. +# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32 +# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/ +# pins them together, and the outer walkable schema uses it as the +# connection_slots bound (per-platform schemas tighten it). _IDF_MAX_CONNECTIONS = 9 @@ -147,17 +149,99 @@ def _validate_no_active(config: ConfigType) -> ConfigType: return config -# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement -# callback feeds the same API batching. GATT/active connections are excluded at -# compile time — only the esp32 build compiles the connection stack; nothing -# reads HubCapabilities::gatt at runtime for this today. -# Keys both platform schemas must declare identically; each arm spreads this -# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays -# per-arm: its default differs (esp32 True, hub arms False — no GATT). +@functools.cache +def _rp2_config_schema() -> cv.All: + """Full proxy on the rp2 BLE hub: active connections through the BTstack + GATT client backend in bluetooth_connection. The slot limit comes from the + prebuilt BTstack library (one connection today); the code is built for N.""" + from esphome.components import rp2040_ble + + connection_schema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), + cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( + bluetooth_connection.RP2GattClient + ), + } + ) + + def populate_connections(config: ConfigType) -> ConfigType: + # One wrapper + backend pair per slot, declared during validation so + # their ids exist for codegen (the esp32 arm's `connections` pattern). + if not config[CONF_ACTIVE]: + return config + return { + **config, + CONF_CONNECTIONS: [ + connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS]) + ], + } + + max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] + schema = ( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + # The GATT backend drives the controller directly (connect, GATT + # ops), not through the tracker hub. + cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( + rp2040_ble.RP2040BLE + ), + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + cv.Optional( + CONF_CONNECTION_SLOTS, + default=min(DEFAULT_CONNECTION_SLOTS, max_conn), + ): cv.All( + cv.positive_int, + cv.Range( + min=1, + max=max_conn, + msg=f"rp2 supports at most {max_conn} connection slot(s); " + "the framework's BTstack library is built with " + f"MAX_NR_GATT_CLIENTS {max_conn}", + ), + ), + } + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA) + ) + return cv.All(schema, populate_connections) + + +async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + from esphome.components import rp2040_ble + + # One wrapper + backend pair per slot (the esp32 arm's pattern). + for connection_conf in config[CONF_CONNECTIONS]: + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) + await cg.register_component(backend, connection_conf) + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + connection = cg.new_Pvariable(connection_conf[CONF_ID]) + cg.add(connection.set_backend(backend)) + cg.add(var.register_connection(connection)) + + +# Per-platform schema builders and connection codegen; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by +# tests/component_tests/bluetooth_proxy/). +_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} +_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} + + +# Keys every platform arm declares identically; each arm spreads this dict so +# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default +# differs (esp32 True, rp2 True, advertisement-only False). _COMMON_SCHEMA_KEYS = { cv.GenerateID(): cv.declare_id(BluetoothProxy), } +# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement +# callback feeds the same API batching, no connection stack compiled. _BLE_HUB_CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -178,9 +262,10 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All( def _validate_platform(config: ConfigType) -> ConfigType: """Apply the schema for the platform actually being compiled. - esp32 keeps the full GATT proxy; every other platform gets the - advertisement-only shape, which rejects the connection-oriented options - above because its schema does not define them. + Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS + platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get + the advertisement-only shape; unsupported keys were already rejected by + name in _reject_unsupported_connection_keys. """ if config is SCHEMA_EXTRACT: # The language-schema dumper runs without a platform. Expose the esp32 @@ -196,18 +281,21 @@ def _validate_platform(config: ConfigType) -> ConfigType: raise cv.Invalid( f"bluetooth_proxy is not supported on {CORE.target_platform}: no " "active-scan-capable BLE tracker hub is available for this " - "platform. It runs on esp32 (full proxy), and the ln882x and rp2 " - "families (advertisement-only)." + "platform. It runs on esp32 and rp2 (full proxy) and the ln882x " + "family (advertisement-only)." ) + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) return _BLE_HUB_CONFIG_SCHEMA(config) -def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: - """Reject connection-oriented options by name on hub-only platforms. +def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType: + """Reject connection options a platform does not support, by name. - Runs before the walkable schema below so the user gets "this option does - not exist here" instead of the option's esp32 value range (which would - imply a smaller number is accepted). + GATT hub platforms keep connection_slots but reject the esp32-only keys; + advertisement-only hubs reject all three. Runs before the walkable schema + below so the user gets "this option does not exist here" instead of a + value-range error implying the option works. """ if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None: return config @@ -216,14 +304,28 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: # reports "not supported on {platform}" instead of a key-level message # implying an advertisement-only proxy is available. return config - for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS): + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + # Full proxy: connection_slots is real here; the per-connection list + # exists internally but carries no user options, and the Bluedroid + # NVS service cache is esp32-only. + rejected = { + CONF_CONNECTIONS: ( + "has no per-connection options on this platform; use " + "'connection_slots' to set the count" + ), + CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)", + } + else: + reason = ( + "requires active connection support; this platform runs the " + "advertisement-only proxy and has no such option" + ) + rejected = dict.fromkeys( + (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason + ) + for key, reason in rejected.items(): if key in config: - raise cv.Invalid( - f"'{key}' requires active connection support, which needs the " - "esp32 GATT stack; this platform runs the advertisement-only " - "proxy and has no such option", - path=[key], - ) + raise cv.Invalid(f"'{key}' {reason}", path=[key]) return config @@ -241,11 +343,14 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType: # rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched # for _ESP32_CONFIG_SCHEMA to validate exactly once. CONFIG_SCHEMA = cv.All( - _reject_connection_keys_off_esp32, + _reject_unsupported_connection_keys, cv.Schema( { cv.Optional(CONF_ACTIVE): cv.boolean, cv.Optional(CONF_CACHE_SERVICES): cv.boolean, + # Bounded by the loosest platform cap so range walkers (the + # device-builder field-range sync) see a real Range; the + # per-platform schemas tighten it (1 on rp2) with their own error. cv.Optional(CONF_CONNECTION_SLOTS): cv.All( cv.positive_int, cv.Range(min=1, max=_IDF_MAX_CONNECTIONS), @@ -295,8 +400,15 @@ async def _to_code_ble_hub(config: ConfigType) -> None: cg.add(var.set_ble_hub(hub)) # The api component sizes BluetoothConnectionsFreeResponse.allocated with - # this define whenever a proxy is present; no connections off-esp32. - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0) + # this define whenever a proxy is present. Zero on advertisement-only hubs. + # Sized from the instantiated connections so the define can never diverge + # from the loop below (the define sizes fixed storage in the proxy). + slots = len(config.get(CONF_CONNECTIONS, ())) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) + if not slots: + return + + await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 4baee7e234..e49dceb000 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -6,6 +6,8 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] +CONF_RP2040_BLE_ID = "rp2040_ble_id" + rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index cfdd78f729..5840185768 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -12,6 +12,7 @@ Scan modes: import esphome.codegen as cg from esphome.components import ble_device_base, ota, rp2040_ble from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE, @@ -22,8 +23,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -CONF_RP2040_BLE_ID = "rp2040_ble_id" - DEPENDENCIES = ["rp2"] AUTO_LOAD = ["ble_device_base", "rp2040_ble"] CODEOWNERS = ["@bdraco"] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 63b9c87918..1685467a4b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -253,10 +253,13 @@ #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY // Mirror the codegen values per platform: _to_code_esp32() emits the connection -// count (default 3), _to_code_ble_hub() emits 0 — so static analysis checks the -// same std::array instantiation a real build produces. +// count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on +// advertisement-only hubs) — so static analysis checks the same +// std::array instantiation a real build produces. #ifdef USE_ESP32 #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#elif defined(USE_RP2) +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif diff --git a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py index 3042c91f84..58e463b32a 100644 --- a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py @@ -1,10 +1,10 @@ """bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together. -The mirror exists so the statically walkable CONFIG_SCHEMA can express the -connection_slots range without importing the esp32 BLE stack (that import -registers esp32-only automations on every platform). The runtime check in -_esp32_config_schema() only fires while validating an esp32 config, so this -test is what actually catches drift when the upstream constant changes. +The mirror doubles as the outer CONFIG_SCHEMA's connection_slots bound, and +the esp32 schema builder lazily imports esp32_ble and asserts the two values +agree, but that assert only fires while building the esp32 schema. This test +catches drift when the upstream constant changes without any esp32 config +being validated. """ from esphome.components import esp32_ble diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 17a05a67b9..32e5daf4bb 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -4,8 +4,10 @@ them without importing the esp32 BLE stack; pin the two declarations together. The outer schema carries no defaults (the per-platform schema applies them), so drift cannot surface in validation output — a key renamed or removed in _esp32_config_schema() but not here would silently vanish from the dashboard's -field extractor. This test is what catches that; validator bounds are pinned -separately only for connection_slots (test_idf_max_connections_mirror). +field extractor. This test is what catches that. The outer schema bounds +connection_slots with the loosest platform cap (_IDF_MAX_CONNECTIONS) so range +walkers see a real Range; per-platform schemas tighten it, and the cap itself +is pinned by test_idf_max_connections_mirror. """ import voluptuous as vol diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 036530d942..55e6fe2ca7 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -2,6 +2,9 @@ real reason, hub platforms reject GATT-only options by name, and the advertisement-only arm applies its own defaults.""" +from pathlib import Path +import re + import pytest from esphome import config_validation as cv @@ -19,9 +22,10 @@ from esphome.core import CORE from ..types import SetCoreConfigCallable +# Advertisement-only hub platforms; rp2 runs the full proxy and has its own +# tests below. HUB_PLATFORM_FRAMEWORKS = [ PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2_ARDUINO, ] HUB_TRACKERS = { @@ -32,9 +36,11 @@ HUB_TRACKERS = { def test_hub_platform_list_covers_every_hub_platform() -> None: # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise - # get no gate coverage at all. - covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} - assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + # get no gate coverage at all; GATT platforms have their own tests. + advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set( + bluetooth_connection.HUB_MAX_CONNECTIONS + ) + assert {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} == advertisement_only assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS) @@ -122,6 +128,55 @@ def test_hub_platform_accepts_the_advertisement_only_shape( assert validated[CONF_ACTIVE] is False +def test_rp2_defaults_to_the_full_proxy( + set_core_config: SetCoreConfigCallable, +) -> None: + # esp32 parity: active defaults to true, with the platform's slot limit, + # and one populated connection entry for the codegen to index. + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + validated = bluetooth_proxy.CONFIG_SCHEMA({}) + assert validated[CONF_ACTIVE] is True + assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 1 + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 + + +def test_rp2_accepts_explicit_passive( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + validated = bluetooth_proxy.CONFIG_SCHEMA({CONF_ACTIVE: False}) + assert validated[CONF_ACTIVE] is False + assert bluetooth_proxy.CONF_CONNECTIONS not in validated + + +def test_rp2_rejects_slots_beyond_the_btstack_limit( + set_core_config: SetCoreConfigCallable, +) -> None: + # The prebuilt BTstack library allows exactly one GATT client connection. + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + with pytest.raises(cv.Invalid, match="at most 1 connection slot"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + # Values past even the loosest platform cap stop at the outer walkable + # schema, which stays bounded for range walkers (device-builder sync); + # in-range values get the platform message above. + with pytest.raises(cv.Invalid, match="at most 9"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 12}) + + +def test_rp2_rejects_esp32_only_keys_by_name( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.RP2_ARDUINO) + _register_tracker(PLATFORM_RP2) + with pytest.raises(cv.Invalid, match="'cache_services' is esp32-only"): + bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True}) + with pytest.raises(cv.Invalid, match="'connections' has no per-connection options"): + bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) + + def test_bluetooth_connection_auto_load_covers_its_includes() -> None: # The esp32 connection header includes esp32_ble_client; the auto load # must satisfy that closure itself (regression: it once relied on the @@ -134,3 +189,40 @@ def test_bluetooth_connection_auto_load_covers_its_includes() -> None: # dependency closures stay complete for build_codeowners and friends. _set_platform(None) assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] + + +def test_every_registered_hub_platform_has_a_schema_arm() -> None: + # A platform added to HUB_MAX_CONNECTIONS without a schema builder, + # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for + # it is validated (or not even then); pin all three couplings here. + registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) + assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) + assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) + assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # The outer walkable schema's bound must stay the loosest platform cap. + assert ( + max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) + <= bluetooth_proxy._IDF_MAX_CONNECTIONS + ) + + +def test_defines_h_mirrors_the_rp2_slot_cap() -> None: + # esphome/core/defines.h carries a literal BLUETOOTH_PROXY_MAX_CONNECTIONS + # for static analysis; pin it to the real rp2 cap. + defines = (Path(__file__).parents[3] / "esphome" / "core" / "defines.h").read_text() + cap = bluetooth_connection.RP2_MAX_CONNECTIONS + # The rp2 arm's define, tolerating blank/comment lines in between. + match = re.search( + r"#elif defined\(USE_RP2\)\s*(?:(?://[^\n]*)?\n)+#define BLUETOOTH_PROXY_MAX_CONNECTIONS (\d+)", + defines, + ) + assert match is not None, "no USE_RP2 arm defines BLUETOOTH_PROXY_MAX_CONNECTIONS" + assert int(match.group(1)) == cap, ( + f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" + ) + # The static-analysis client count scales with the same cap. + match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h" + assert int(match.group(1)) == cap, ( + f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + ) diff --git a/tests/components/bluetooth_connection/common.yaml b/tests/components/bluetooth_connection/common.yaml new file mode 100644 index 0000000000..5e84f4a678 --- /dev/null +++ b/tests/components/bluetooth_connection/common.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + +api: diff --git a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..620aaa177b --- /dev/null +++ b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Distinct shape from bluetooth_proxy's own rp2 fixtures: explicit slot count +# on the platform whose backend lives in this component (validate-only, so it +# never collides with grouped builds). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: + active: true + connection_slots: 1 diff --git a/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml new file mode 100644 index 0000000000..ae0f00d765 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.rp2040-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on rp2 by explicit choice. Variant tests compile +# as their own builds when this component is tested individually; under CI +# batch grouping the active default build is what runs, so this fixture's +# guarantee is the individual run plus config validation. +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml index fd327bcc78..e219c7542d 100644 --- a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -1,5 +1,5 @@ -# Advertisement-only proxy on the rp2 BLE hub — the one non-esp32 platform the -# proxy admits today (active-scan-capable), and a target CI fully compiles. +# Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity), +# so this compiles the BTstack GATT client backend and one connection slot. # No explicit ble_hub_id: the generated binding resolves the single declared # hub, and an inline id here would collide with rp2_ble_tracker's own fixture # once CI merges both components into one grouped rp2040-ard build (grouped From c24e61439b374be455dc8f6e4af69aa080249a00 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 7 Aug 2026 21:17:11 +0200 Subject: [PATCH 015/597] [modbus] Add server support for read/write multiple registers (0x17) (#17357) Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 109 +++++++++++++++--- esphome/components/modbus/modbus.h | 25 ++-- .../components/modbus/modbus_definitions.h | 12 +- .../components/modbus/modbus_helpers_test.cpp | 12 ++ .../uart_mock_modbus_server_read_write.yaml | 106 +++++++++++++++++ ...mock_modbus_server_read_write_invalid.yaml | 81 +++++++++++++ tests/integration/test_uart_mock_modbus.py | 93 +++++++++++++++ 7 files changed, 407 insertions(+), 31 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 57371b9e79..db97d56cc6 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -376,6 +376,50 @@ bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_co return true; } +bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, + uint16_t number_of_registers, const RegisterValues ®isters, + std::span response_buffer, uint16_t &response_len) { + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return false; + } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + // The byte count is a single byte, so the count must stay within the protocol read limit; above it the + // static_cast(number_of_registers * 2) below would silently truncate the byte count. + if (number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "Read response of %" PRIu16 " registers exceeds the limit of %" PRIu16, number_of_registers, + MAX_NUM_OF_REGISTERS_TO_READ); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + // Byte count(1) + two bytes per register. Checked here rather than at the call sites so the bound travels with + // the write itself: a future caller starting at a non-zero response_len, or passing a smaller buffer, is + // rejected instead of overrunning it before send_response_'s size guard can fire. + const size_t required = static_cast(response_len) + 1 + static_cast(number_of_registers) * 2; + if (required > response_buffer.size()) { + ESP_LOGE(TAG, "Read response needs %zu bytes but only %zu are available", required, response_buffer.size()); + this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); + return false; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } + return true; +} + void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { ModbusServerDevice *device = this->find_device_(address); if (device == nullptr) { @@ -410,25 +454,10 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func status = device->on_read_input_registers(start_address, number_of_registers, registers); } - // A handler that returns an exception leaves registers partially filled, so check the exception - // first and forward it before validating the register count on the success path. - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers, + response_buffer, response_len)) { return; } - - if (registers.size() != number_of_registers) { - ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); - this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE); - return; - } - - response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count - for (auto r : registers) { - auto register_bytes = decode_value(r); - response_buffer[response_len++] = register_bytes[0]; - response_buffer[response_len++] = register_bytes[1]; - } break; } case FunctionCode::WRITE_SINGLE_REGISTER: @@ -465,6 +494,52 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func response_len = 4; break; } + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { + // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. + uint16_t read_start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = helpers::get_data(data, 2); + uint16_t write_start_address = helpers::get_data(data, 4); + uint16_t number_of_write_registers = helpers::get_data(data, 6); + uint8_t number_of_bytes = helpers::get_data(data, 8); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ || + number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW || + number_of_write_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers (read %" PRIu16 ", write %" PRIu16 ") or bytes %" PRIu8, + number_of_registers, number_of_write_registers, number_of_bytes); + this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, read_start_address, number_of_registers) || + !this->check_register_range_(address, function_code, write_start_address, number_of_write_registers)) { + return; + } + // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read + // values are allocated, keeping only one RegisterValues buffer live at a time. + { + // Assemble the written register values (host byte order); they follow the 9-byte request header. + RegisterValues write_registers; + for (uint16_t i = 0; i < number_of_write_registers; i++) { + write_registers.push_back(helpers::get_data(data, 9 + i * 2)); + } + // Dispatch to the standalone write and read handlers so any device implementing those supports 0x17 + // without a dedicated handler; a device that maps registers by address reconstructs the read response + // from the values it just stored. + status = device->on_write_registers(write_start_address, write_registers); + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; + } + RegisterValues registers; + status = device->on_read_holding_registers(read_start_address, number_of_registers, registers); + + if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers, + response_buffer, response_len)) { + return; + } + break; + } default: ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION); diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index c73aa6878d..9f88213985 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -312,6 +312,14 @@ class ModbusClientHub : public Modbus { std::deque tx_buffer_; }; +// Transaction status: std::nullopt on success, otherwise a Modbus exception code +using ResponseStatus = std::optional; + +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + class ModbusServerHub : public Modbus { public: ModbusServerHub() = default; @@ -328,6 +336,15 @@ class ModbusServerHub : public Modbus { // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_registers); + + // Builds the body of a register read response (byte count followed by the big-endian register values) into + // response_buffer. Shared by every function code that answers with register values, so the read reply stays + // identical across them. Returns false once an exception has been sent: the one the handler reported via + // status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the + // protocol read limit, or the body does not fit. + bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, + uint16_t number_of_registers, const RegisterValues ®isters, + std::span response_buffer, uint16_t &response_len); void send_raw_(const uint8_t *payload, uint16_t len); void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); @@ -340,9 +357,6 @@ class ModbusServerHub : public Modbus { uint16_t deferred_payload_len_{0}; }; -// Transaction status: std::nullopt on success, otherwise a Modbus exception code -using ResponseStatus = std::optional; - /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by /// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) @@ -563,11 +577,6 @@ class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_e } }; -// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol -// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by -// the capacity of this type. -using RegisterValues = StaticVector; - class ModbusServerDevice { public: virtual ~ModbusServerDevice() = default; diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index f883bfff30..b55b3ebe01 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -33,12 +33,12 @@ enum class FunctionCode : uint8_t { GET_COMM_EVENT_LOG = 0x0C, // not implemented WRITE_MULTIPLE_COILS = 0x0F, WRITE_MULTIPLE_REGISTERS = 0x10, - REPORT_SERVER_ID = 0x11, // not implemented - READ_FILE_RECORD = 0x14, // not implemented - WRITE_FILE_RECORD = 0x15, // not implemented - MASK_WRITE_REGISTER = 0x16, // not implemented - READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented - READ_FIFO_QUEUE = 0x18, // not implemented + REPORT_SERVER_ID = 0x11, // not implemented + READ_FILE_RECORD = 0x14, // not implemented + WRITE_FILE_RECORD = 0x15, // not implemented + MASK_WRITE_REGISTER = 0x16, // not implemented + READ_WRITE_MULTIPLE_REGISTERS = 0x17, + READ_FIFO_QUEUE = 0x18, // not implemented }; // Remove before 2027.2.0 diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 553ec163b2..768c23c33c 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -92,6 +92,18 @@ TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) { EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2); } +TEST(ModbusClientFrameLength, ReadWriteMultipleUsesByteCount) { + // read start(2) + read qty(2) + write start(2) + write qty(2) + byte count(1) then data + const uint8_t frame[] = {0x01, 0x17, 0x9C, 0xB9, 0x00, 0x02, 0x9C, 0x41, 0x00, 0x02, 0x04, 0xAA, 0xBB, 0xCC, 0xDD}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 13 + 4); +} + +TEST(ModbusClientFrameLength, ReadWriteMultipleMissingByteCount) { + // header present up to the write quantity but the byte count byte (frame[10]) is absent + const uint8_t frame[] = {0x01, 0x17, 0x9C, 0xB9, 0x00, 0x02, 0x9C, 0x41, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 13); +} + TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml new file mode 100644 index 0000000000..e998861c2d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml @@ -0,0 +1,106 @@ +esphome: + name: uart-mock-modbus-srv-rw + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + # FC 0x17 Read/Write Multiple Registers on device 1: + # write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2). + # Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must + # read back the just-written 0x1234 in the same request. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] + # FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) - + # a write and read targeting a different register block. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10] + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + - id: stored_3 + type: uint16_t + initial_value: "0" + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # Writable + readable register backed by a global. The read publishes what it + # returns so the test can confirm the write half ran before the read half. + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(rw_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(rw_write_1).publish_state(x); + return true; + # Read-only register, read together with 0x01 by the first request's 2-register read. + - address: 0x02 + value_type: U_WORD + read_lambda: |- + id(rw_read_2).publish_state(0x00AA); + return 0x00AA; + # Second writable + readable register, targeted by the second request. + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(rw_read_3).publish_state(id(stored_3)); + return id(stored_3); + write_lambda: |- + id(stored_3) = x; + id(rw_write_3).publish_state(x); + return true; + +sensor: + - platform: template + name: "rw_write_1" + id: rw_write_1 + - platform: template + name: "rw_read_1" + id: rw_read_1 + - platform: template + name: "rw_read_2" + id: rw_read_2 + - platform: template + name: "rw_write_3" + id: rw_write_3 + - platform: template + name: "rw_read_3" + id: rw_read_3 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml b/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml new file mode 100644 index 0000000000..d3c091d67d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_read_write_invalid.yaml @@ -0,0 +1,81 @@ +esphome: + name: uart-mock-modbus-srv-rw-inv + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + # Malformed FC 0x17 Read/Write Multiple Registers, otherwise well formed (valid CRC): write + # quantity 2 but byte count 2 (2 registers need 4 bytes), i.e. byte count != 2x write quantity. + # The hub must reject it (ILLEGAL_DATA_VALUE) before touching any register. + - delay: 100ms + inject_rx: + [0x01, 0x17, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02, 0x02, 0x12, 0x34, 0x09, 0x89] + # A valid FC 0x03 read of reg 0x0A injected afterwards. Its read_lambda fires the "probe" + # sensor, which (because injections run in order) signals the malformed frame was processed. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08] + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_server: + - address: 1 + registers: + # The malformed request's write half spans 0x01-0x02. Both are registered so modbus_server's + # address pre-flight cannot reject the frame on its own: if the hub wrongly accepted it, these + # write_lambdas would fire the "write_seen" sensor. + - address: 0x01 + value_type: U_WORD + read_lambda: return 0; + write_lambda: |- + id(write_seen).publish_state(1); + return true; + - address: 0x02 + value_type: U_WORD + read_lambda: return 0; + write_lambda: |- + id(write_seen).publish_state(1); + return true; + # Processing probe: a valid read of this register fires after the malformed frame. + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(probe).publish_state(1); + return 1; + +sensor: + - platform: template + name: "write_seen" + id: write_seen + - platform: template + name: "probe" + id: probe + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index bf163665f2..75adcc0e3d 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -203,6 +203,99 @@ async def test_uart_mock_modbus_server( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server FC 0x17 (read/write multiple registers). + + Injects raw 0x17 request frames and checks the round-trip through the + server's read_lambda/write_lambda, independent of how the hub dispatches + 0x17 internally: + * one request writes reg 0x01 then reads regs 0x01+0x02 -- reg 0x01 reads + back the just-written value (the write happens before the read per + Modbus 6.17), and the second register is returned by the same + multi-register read; + * a second request writes and reads a different register block. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["rw_write_1", "rw_read_1", "rw_read_2", "rw_write_3", "rw_read_3"] + ) + futures = tracker.expect_all( + { + "rw_write_1": 4660, # 0x1234 written to reg 0x0001 + "rw_read_1": 4660, # reg 0x0001 reads back the just-written value + "rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request + "rw_write_3": 22136, # 0x5678 written to reg 0x0003 + "rw_read_3": 22136, # reg 0x0003 reads back the just-written value + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_read_write_invalid( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server FC 0x17 invalid-frame handling. + + Injects a well-formed (valid CRC) 0x17 request whose write byte count (2) + does not match 2x the write quantity (2 registers need 4 bytes), so the hub + must reject it with ILLEGAL_DATA_VALUE before touching any register. A valid + read is injected right after as a processing marker. + + The invalid frame is verified via bus-level signals rather than the reply + frame on the wire: the mock UART cannot observe the server's TX reliably on + the host platform (the server's transmission is gated by a millis()-based tx + delay), so instead we assert the request is rejected exactly once and never + applied to a register. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker(["write_seen", "probe"]) + probe_seen = tracker.expect("probe", 1) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + # The probe read is injected after the malformed frame, so once it fires + # the malformed frame has already been processed. + await tracker.await_change(probe_seen, "probe") + + # Exactly one bus-level rejection for the malformed frame (no cascade)... + invalid_warnings = [ + line for line in warning_log_lines if "Invalid number of registers" in line + ] + assert len(invalid_warnings) == 1, ( + "Expected exactly one invalid-frame rejection, got warnings:\n" + + "\n".join(warning_log_lines) + ) + assert len(error_log_lines) == 0, ( + "Expected no modbus errors, but got:\n" + "\n".join(error_log_lines) + ) + # ...and the rejected write is never applied to the target register. + assert not tracker.sensor_states["write_seen"], ( + f"malformed 0x17 must not write, but write_seen fired: {tracker.sensor_states['write_seen']}" + ) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller( yaml_config: str, From ee13996ee64b8cc804062f96f31821db429537c2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:17:26 +0000 Subject: [PATCH 016/597] Bump bundled esphome-device-builder to 1.9.4 (#18162) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e928fd37ca..c0f7222bca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4 RUN \ platformio settings set enable_telemetry No \ From f5ee72753dcf273821ee84812720780966f742f9 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 7 Aug 2026 14:18:20 -0500 Subject: [PATCH 017/597] [modbus_client] Add typed read/write actions (#18078) Co-authored-by: J. Nick Koston Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 8 + esphome/components/modbus/modbus_helpers.cpp | 24 +- esphome/components/modbus/modbus_helpers.h | 16 +- esphome/components/modbus_client/__init__.py | 277 +++++++++++++++++- .../components/modbus_client/modbus_client.h | 251 +++++++++++++++- tests/components/modbus_client/common.yaml | 62 ++++ .../uart_mock_modbus_client_typed.yaml | 176 +++++++++++ tests/integration/test_uart_mock_modbus.py | 52 ++++ 8 files changed, 847 insertions(+), 19 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_client_typed.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index bc52263aef..c91032801b 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -21,6 +21,14 @@ AUTO_LOAD = ["modbus_client"] # Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC. MAX_PDU_SIZE = 253 +# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the +# C++ constants of the same name; the spec sets a different ceiling for each function code. +MAX_NUM_OF_COILS_TO_READ = 2000 +MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 +MAX_NUM_OF_COILS_TO_WRITE = 1968 +MAX_NUM_OF_REGISTERS_TO_READ = 125 +MAX_NUM_OF_REGISTERS_TO_WRITE = 123 + modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus) diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 2c87928e9f..a0c8440c79 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -534,23 +534,33 @@ PduBuffer create_write_coils_pdu(uint16_t start_address, PackedBits bits) { return pdu; } -PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { +// Shared by the two bool-container overloads: both index the same way, so the packing is written once. +template +static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const BoolContainer &values) { PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) + const size_t count = values.size(); // Bound before packing so the transient buffer below cannot overflow; the shared core validates the rest. - if (values.size() > MAX_NUM_OF_COILS_TO_WRITE) { - ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", values.size(), + if (count > MAX_NUM_OF_COILS_TO_WRITE) { + ESP_LOGE(TAG, "values.size() %zu exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); return pdu; } - StaticVector packed; - for (size_t i = 0; i != values.size(); i++) { + CoilPackBuffer packed; + for (size_t i = 0; i != count; i++) { if (i % 8 == 0) packed.push_back(0); if (values[i]) packed[i / 8] |= (1 << (i % 8)); } - build_write_coils_pdu(pdu, start_address, - PackedBits(std::span(packed.data(), packed.size()), values.size())); + build_write_coils_pdu(pdu, start_address, PackedBits(std::span(packed.data(), packed.size()), count)); return pdu; } + +PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values) { + return create_write_coils_pdu_from_bools(start_address, values); +} + +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values) { + return create_write_coils_pdu_from_bools(start_address, values); +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 36e3b6c7be..2c312b8a61 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -366,6 +366,8 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou using PduBuffer = StaticVector; using ReadPdu = StaticVector; using WriteSinglePdu = StaticVector; +/// Scratch space for packing coils into wire layout: one bit per coil, sized for the spec maximum. +using CoilPackBuffer = StaticVector; /** Create a modbus read request PDU. * @param function_code one of READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS @@ -427,12 +429,22 @@ WriteSinglePdu create_write_single_coil_pdu(uint16_t address, bool value); * Function 0x0F Write Multiple Coils * @param start_address modbus address of the first coil to write * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an - * over-long set is rejected and an empty PDU is returned). Note std::vector is bit-packed and - * does not convert to a span; pass a std::array or other contiguous bool container. + * over-long set is rejected and an empty PDU is returned) * @return PDU (function code + data, no address, no CRC) */ PduBuffer create_write_coils_pdu(uint16_t start_address, std::span values); +/** Create modbus write multiple coils command (function 0x0F) from a std::vector. + * Prefer the span overload above whenever the coils are already in contiguous storage - a std::array + * or any other contiguous bool container converts to it. This overload exists only because std::vector + * is bit-packed and so cannot convert to a span; without it every caller holding one re-implements the packing. + * @param start_address modbus address of the first coil to write + * @param values coil values to write; the coil count is values.size() (at most MAX_NUM_OF_COILS_TO_WRITE, an + * over-long set is rejected and an empty PDU is returned) + * @return PDU (function code + data, no address, no CRC) + */ +PduBuffer create_write_coils_pdu(uint16_t start_address, const std::vector &values); + /** Create modbus write multiple coils command (function 0x0F) from bits packed as on the wire. * @param start_address modbus address of the first coil to write * @param bits PackedBits view of the coils to write (at most MAX_NUM_OF_COILS_TO_WRITE); invalid diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index e8a75a1b6c..538936c93b 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -1,24 +1,58 @@ +from collections.abc import Callable +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import modbus import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE -from esphome.core import Lambda +from esphome.const import ( + CONF_ADDRESS, + CONF_COUNT, + CONF_ON_ERROR, + CONF_ON_RESPONSE, + CONF_VALUE, +) +from esphome.core import ID, Lambda from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +CONF_ON_CUSTOM_RESPONSE = "on_custom_response" CONF_ON_NO_RESPONSE = "on_no_response" CONF_ON_NOT_SENT = "on_not_sent" CONF_ON_SENT = "on_sent" CONF_PDU = "pdu" CONF_RETRY = "retry" +CONF_START_ADDRESS = "start_address" +CONF_VALUES = "values" modbus_client_ns = cg.esphome_ns.namespace("modbus_client") ModbusClientSendAction = modbus_client_ns.class_( "ModbusClientSendAction", automation.Action, modbus.ModbusClientDevice ) +ReadRegistersAction = modbus_client_ns.class_( + "ReadRegistersAction", automation.Action, modbus.ModbusClientDevice +) +WriteSingleRegisterAction = modbus_client_ns.class_( + "WriteSingleRegisterAction", automation.Action, modbus.ModbusClientDevice +) +WriteSingleCoilAction = modbus_client_ns.class_( + "WriteSingleCoilAction", automation.Action, modbus.ModbusClientDevice +) +ReadBitsAction = modbus_client_ns.class_( + "ReadBitsAction", automation.Action, modbus.ModbusClientDevice +) + +WriteMultipleRegistersAction = modbus_client_ns.class_( + "WriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) +WriteMultipleCoilsAction = modbus_client_ns.class_( + "WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice +) + +# Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers. +PackedBits = modbus.modbus_ns.class_("PackedBits") # The exception code passed to on_error handlers. ExceptionCode = modbus.modbus_ns.enum("ExceptionCode") @@ -34,6 +68,11 @@ _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) _PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") +def _packed_bit_bytes(bits: int) -> int: + """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" + return (bits + 7) // 8 + + def _synchronous_handler(value: ConfigType) -> ConfigType: """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" @@ -108,6 +147,11 @@ async def register_client_action( await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) ) ) + # Present for every typed action and absent from modbus_client.send, which has a pdu instead. + if (start_address := config.get(CONF_START_ADDRESS)) is not None: + cg.add( + var.set_start_address(await cg.templatable(start_address, args, cg.uint16)) + ) if sent_conf := config.get(CONF_ON_SENT): await automation.build_automation( var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf @@ -116,6 +160,15 @@ async def register_client_action( await automation.build_automation( var.get_response_trigger(), response_args, response_conf ) + if custom_conf := config.get(CONF_ON_CUSTOM_RESPONSE): + # Tell the action a handler exists; without this it falls back to the base's warn-once log so an + # unhandled diverted reply is still reported instead of firing an empty trigger. + cg.add(var.set_custom_response_handled()) + await automation.build_automation( + var.get_custom_response_trigger(), + [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], + custom_conf, + ) if error_conf := config.get(CONF_ON_ERROR): await automation.build_automation( var.get_error_trigger(), @@ -162,3 +215,223 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): args, [(_PDU_SPAN, "request"), (_PDU_SPAN, "response")], ) + + +# --- Typed actions: request PDUs come from the device base's typed senders, replies from its dispatch, +# --- so on_response delivers decoded arguments (host-order words) instead of raw PDU spans. + +_REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const")) + +# Every typed action addresses a register or coil range and reports through the same two reply handlers. +_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend( + { + cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), + # Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the + # hub reuses once the handler returns, so a deferring action would resume on freed memory. + cv.Optional(CONF_ON_RESPONSE): _handler_schema(), + # A reply the dispatch gate diverts (not a standard-conformant transaction) arrives here with the + # raw request/response PDUs; real device exceptions still arrive via on_error. + cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(), + } +) + + +def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: + """Reject a range that runs past the 16-bit address space, which the device could never answer. + + Only literal configurations can be checked: either operand may be a lambda, and its value is not known + until play(). The PDU builders repeat this check at runtime, so the lambda case is still rejected and + logged - just later. + """ + + def validate(config: ConfigType) -> ConfigType: + start = config[CONF_START_ADDRESS] + count = config[count_key] + if isinstance(start, Lambda) or isinstance(count, Lambda): + return config + # CONF_COUNT is a number; CONF_VALUES is the list whose length is the count. + length = count if isinstance(count, int) else len(count) + if start + length > 0x10000: + raise cv.Invalid( + f"{CONF_START_ADDRESS} 0x{start:04X} plus {length} entities runs past the end of the " + f"16-bit address space (last addressable entity is 0xFFFF)", + path=[CONF_START_ADDRESS], + ) + return config + + return validate + + +def _read_schema(max_count: int) -> cv.All: + """Read action schema. The spec sets the read ceiling per function code, so each one passes its own.""" + return cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Optional(CONF_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=max_count) + ), + } + ), + _no_address_overflow(CONF_COUNT), + ) + + +def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.All: + """Multi-write action schema, differing only in the element type and the spec's per-function limit.""" + return cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUES): cv.templatable( + cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) + ), + } + ), + _no_address_overflow(CONF_VALUES), + ) + + +_READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) + +_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( + {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +) + +# A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. +_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( + {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +) + + +async def _read_registers_to_code(config, action_id, template_arg, args, holding): + var = cg.new_Pvariable(action_id, template_arg, holding) + cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) + + +@automation.register_action( + "modbus_client.read_holding_registers", + ReadRegistersAction, + _READ_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_holding_registers_to_code(config, action_id, template_arg, args): + return await _read_registers_to_code(config, action_id, template_arg, args, True) + + +@automation.register_action( + "modbus_client.read_input_registers", + ReadRegistersAction, + _READ_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_input_registers_to_code(config, action_id, template_arg, args): + return await _read_registers_to_code(config, action_id, template_arg, args, False) + + +async def _write_single_to_code(config, action_id, template_arg, args, value_type): + var = cg.new_Pvariable(action_id, template_arg) + cg.add(var.set_value(await cg.templatable(config[CONF_VALUE], args, value_type))) + return await register_client_action(var, config, args, []) + + +@automation.register_action( + "modbus_client.write_single_register", + WriteSingleRegisterAction, + _WRITE_SINGLE_REGISTER_SCHEMA, + synchronous=True, +) +async def write_single_register_to_code(config, action_id, template_arg, args): + return await _write_single_to_code(config, action_id, template_arg, args, cg.uint16) + + +@automation.register_action( + "modbus_client.write_single_coil", + WriteSingleCoilAction, + _WRITE_SINGLE_COIL_SCHEMA, + synchronous=True, +) +async def write_single_coil_to_code(config, action_id, template_arg, args): + return await _write_single_to_code(config, action_id, template_arg, args, cg.bool_) + + +_READ_COILS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_COILS_TO_READ) +_READ_DISCRETE_INPUTS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) + + +async def _read_bits_to_code(config, action_id, template_arg, args, coils): + var = cg.new_Pvariable(action_id, template_arg, coils) + cg.add(var.set_count(await cg.templatable(config[CONF_COUNT], args, cg.uint16))) + return await register_client_action(var, config, args, [(PackedBits, "bits")]) + + +@automation.register_action( + "modbus_client.read_coils", + ReadBitsAction, + _READ_COILS_SCHEMA, + synchronous=True, +) +async def read_coils_to_code(config, action_id, template_arg, args): + return await _read_bits_to_code(config, action_id, template_arg, args, True) + + +@automation.register_action( + "modbus_client.read_discrete_inputs", + ReadBitsAction, + _READ_DISCRETE_INPUTS_SCHEMA, + synchronous=True, +) +async def read_discrete_inputs_to_code(config, action_id, template_arg, args): + return await _read_bits_to_code(config, action_id, template_arg, args, False) + + +_WRITE_MULTIPLE_REGISTERS_SCHEMA = _write_multiple_schema( + cv.hex_uint16_t, modbus.MAX_NUM_OF_REGISTERS_TO_WRITE +) + +_WRITE_MULTIPLE_COILS_SCHEMA = _write_multiple_schema( + cv.boolean, modbus.MAX_NUM_OF_COILS_TO_WRITE +) + + +@automation.register_action( + "modbus_client.write_multiple_registers", + WriteMultipleRegistersAction, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, []) + + +@automation.register_action( + "modbus_client.write_multiple_coils", + WriteMultipleCoilsAction, + _WRITE_MULTIPLE_COILS_SCHEMA, + synchronous=True, +) +async def write_multiple_coils_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.bool_)) + cg.add(var.set_values_template(templ)) + else: + # Pack to wire layout (LSB first) here, so the runtime neither allocates nor packs. + packed = bytearray(_packed_bit_bytes(len(values))) + for i, coil in enumerate(values): + if coil: + packed[i // 8] |= 1 << (i % 8) + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, []) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index d4b3792a5f..599be85cb8 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include +#include namespace esphome::modbus_client { @@ -57,6 +58,16 @@ template class ClientActionBase : public Action, public m } protected: + /// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full + /// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every + /// send still gets exactly one outcome, so resolve refusals here via on_not_sent. + /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and + /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. + void send_or_resolve_(std::span pdu) { + if (!this->send_pdu(pdu)) + this->on_not_sent(pdu); + } + Trigger> sent_trigger_; Trigger, modbus::ExceptionCode> error_trigger_; Trigger> no_response_trigger_; @@ -79,14 +90,7 @@ template class ModbusClientSendAction : public ClientActionBase< return &this->response_trigger_; } - void play(const Ts &...x) override { - auto pdu = this->pdu_.value(x...); - const std::span span(pdu.data(), pdu.size()); - // The hub refuses some sends at the door with no callback (an empty PDU, a duplicate write already - // pending, a full queue). Every send still gets exactly one outcome, so resolve those via on_not_sent. - if (!this->send_pdu(span)) - this->on_not_sent(span); - } + void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...)); } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -96,4 +100,235 @@ template class ModbusClientSendAction : public ClientActionBase< Trigger, std::span> response_trigger_; }; +/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs +/// the shared dispatch (validation gate + decode) and the typed callbacks below fire directly on the +/// action. A reply the gate diverts (not a standard-conformant transaction) fires the on_custom_response +/// trigger with the raw request/response PDUs, so non-standard replies stay handleable; the spans are only +/// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the +/// response, never with an exception status - real device exceptions arrive via on_error, which +/// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a +/// success status.) +template class TypedClientActionBase : public ClientActionBase { + public: + Trigger, std::span> *get_custom_response_trigger() { + return &this->custom_response_trigger_; + } + /// Set by codegen when the config declares on_custom_response. Without it an unhandled diverted reply + /// would fire an empty trigger and vanish, so the base's warn-once diagnostic has to stay reachable. + void set_custom_response_handled() { this->custom_response_handled_ = true; } + + void on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) override { + if (!this->custom_response_handled_) { + modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status); + return; + } + this->custom_response_trigger_.trigger(request_pdu, response_pdu); + } + + protected: + /// Defensive assertion, not a live branch: ClientActionBase::on_error intercepts every exception reply + /// before the dispatch runs, so a typed callback below is only ever reached with a success status. Kept + /// so a future change to that interception cannot silently deliver an exception as a successful reply. + bool is_success_(modbus::ResponseStatus status) { return !status.has_value(); } + + Trigger, std::span> custom_response_trigger_; + bool custom_response_handled_{false}; +}; + +/// modbus_client.read_holding_registers / read_input_registers: on_response delivers the registers in +/// host byte order as `values` (only valid for the duration of the trigger). +template class ReadRegistersAction : public TypedClientActionBase { + public: + explicit ReadRegistersAction(bool holding) : holding_(holding) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const auto function_code = + this->holding_ ? modbus::FunctionCode::READ_HOLDING_REGISTERS : modbus::FunctionCode::READ_INPUT_REGISTERS; + this->send_or_resolve_( + modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...))); + } + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + bool holding_; +}; + +/// modbus_client.read_coils / read_discrete_inputs: on_response delivers the bits as a PackedBits view +/// (bit 0 = the bit at start_address; only valid for the duration of the trigger). +template class ReadBitsAction : public TypedClientActionBase { + public: + explicit ReadBitsAction(bool coils) : coils_(coils) {} + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, count) + + Trigger *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const auto function_code = + this->coils_ ? modbus::FunctionCode::READ_COILS : modbus::FunctionCode::READ_DISCRETE_INPUTS; + this->send_or_resolve_( + modbus::helpers::create_read_pdu(function_code, this->start_address_.value(x...), this->count_.value(x...))); + } + void on_read_bits(modbus::EntityType entity_type, uint16_t start_address, modbus::PackedBits bits, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(bits); + } + + protected: + Trigger response_trigger_; + bool coils_; +}; + +/// modbus_client.write_single_register: on_response is the acknowledgement (the ack only echoes the +/// request, so it carries no arguments). +template class WriteSingleRegisterAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(uint16_t, value) + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + this->send_or_resolve_( + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + } + void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; +}; + +/// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one +/// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. +template class WriteSingleCoilAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + TEMPLATABLE_VALUE(bool, value) + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + this->send_or_resolve_( + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + } + void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; +}; + +/// modbus_client.write_multiple_registers: on_response is the acknowledgement (no arguments). +/// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a +/// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static +/// list must not allocate on every play(). +template class WriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + + /// Static config: the registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the registers are only known at play() time. Stateless lambdas (all ESPHome + /// generates) convert to a plain function pointer, so this stays pointer-sized. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t start = this->start_address_.value(x...); + // An empty or over-long set rejects into an empty PDU inside the builder, which logs the reason; + // the empty PDU then resolves via on_not_sent like any refused send. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( + start, std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + } + void on_write_multiple_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + +/// modbus_client.write_multiple_coils: on_response is the acknowledgement (no arguments). +/// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() +/// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than +/// a byte - and is packed into a stack buffer on the way to the builder. +template class WriteMultipleCoilsAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, start_address) + + /// Static config: `packed` is the wire layout (LSB first) held in flash, `count` the number of coils. + void set_values_static(const uint8_t *packed, size_t count) { + this->values_.packed = packed; + this->count_ = static_cast(count); + } + /// Lambda config: the coils are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->count_ = -1; // sentinel: template mode + } + + Trigger<> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t start = this->start_address_.value(x...); + if (this->count_ >= 0) { + const auto count = static_cast(this->count_); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( + start, + modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + return; + } + // The builder packs and bound-checks; an over-long set is rejected and logged there. + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + } + void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, + modbus::ResponseStatus status) override { + if (this->is_success_(status)) + this->response_trigger_.trigger(); + } + + protected: + Trigger<> response_trigger_; + ssize_t count_{-1}; // -1 = template mode, >= 0 = static mode with this many coils + union Values { + std::vector (*func)(Ts...); + const uint8_t *packed; + } values_; +}; + } // namespace esphome::modbus_client diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 19e81f4e48..19acbe9b0e 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -51,3 +51,65 @@ button: on_not_sent: then: - lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);' + - platform: template + name: "Typed Actions" + on_press: + - modbus_client.write_single_register: + address: 0x01 + start_address: 0x0102 + value: !lambda "return 42;" + on_response: + then: + - logger.log: "write acked" + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "write exception %d", (int) exception_code);' + - modbus_client.read_holding_registers: + address: !lambda "return 1;" + start_address: 0x10 + count: 2 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' + on_no_response: + then: + - logger.log: "typed read timeout" + - modbus_client.read_input_registers: + address: 0x01 + start_address: 0x20 + on_custom_response: + then: + - lambda: |- + ESP_LOGW("modbus_client.test", "non-standard reply: fc 0x%02X, %u byte request", + response.empty() ? 0 : response[0], (unsigned) request.size()); + - modbus_client.write_single_coil: + address: 0x01 + start_address: 0x01 + value: true + - modbus_client.read_coils: + address: 0x01 + start_address: 0x03 + count: 16 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());' + - modbus_client.read_discrete_inputs: + address: 0x01 + start_address: 0x00 + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.write_multiple_registers: + address: 0x01 + start_address: 0x0200 + values: !lambda "return {1, 2, 3};" + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "multi write acked");' + - modbus_client.write_multiple_coils: + address: 0x01 + start_address: 0x0010 + values: [true, false, true] + on_error: + then: + - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml new file mode 100644 index 0000000000..167ad2c5bb --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -0,0 +1,176 @@ +esphome: + name: uart-mock-modbus-client-typed + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +globals: + - id: reg10 + type: uint16_t + initial_value: "0" + - id: reg11 + type: uint16_t + initial_value: "0" + - id: reg12 + type: uint16_t + initial_value: "0" + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: |- + id(reg10) = x; + return true; + - address: 0x11 + value_type: U_WORD + read_lambda: return id(reg11); + write_lambda: |- + id(reg11) = x; + return true; + - address: 0x12 + value_type: U_WORD + read_lambda: return id(reg12); + write_lambda: |- + id(reg12) = x; + return true; + +sensor: + - platform: template + name: "typed_value" + id: typed_value + - platform: template + name: "ack_flag" + id: ack_flag + - platform: template + name: "error_code" + id: error_code + - platform: template + name: "coil_error_code" + id: coil_error_code + - platform: template + name: "multi_value" + id: multi_value + - platform: template + name: "multi_coil_error" + id: multi_coil_error + - platform: template + name: "not_sent_flag" + id: not_sent_flag + +# Typed actions end to end: a typed write lands on the server (ack -> ack_flag), the typed read-back +# decodes the written value from the reply words (values[0] -> typed_value), and a read of an unserved +# register resolves via on_error with the device's exception code (-> error_code). +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - modbus_client.write_single_register: + address: 1 + start_address: 0x10 + value: 777 + on_response: + then: + - lambda: "id(ack_flag).publish_state(1);" + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x10 + on_response: + then: + - lambda: |- + if (!values.empty()) + id(typed_value).publish_state(values[0]); + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x99 + on_error: + then: + - lambda: "id(error_code).publish_state((int) exception_code);" + # The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read + # action's request PDU and its typed error delivery. + - modbus_client.read_coils: + address: 1 + start_address: 0x00 + count: 8 + on_error: + then: + - lambda: "id(coil_error_code).publish_state((int) exception_code);" + # Multi-register write (fc 0x10, served) then read-back of the second written register. + - modbus_client.write_multiple_registers: + address: 1 + start_address: 0x11 + values: [111, 222] + on_response: + then: + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x12 + on_response: + then: + - lambda: |- + if (!values.empty()) + id(multi_value).publish_state(values[0]); + # A count lambda can go out of spec at runtime: the builder rejects it into an empty PDU, the hub + # refuses that at the door, and the send resolves via on_not_sent (no reply will ever come). + - modbus_client.read_holding_registers: + address: 1 + start_address: 0x10 + count: !lambda "return 0;" + on_not_sent: + then: + - lambda: "id(not_sent_flag).publish_state(1);" + # Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION. + - modbus_client.write_multiple_coils: + address: 1 + start_address: 0x00 + values: [true, false, true] + on_error: + then: + - lambda: "id(multi_coil_error).publish_state((int) exception_code);" diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 75adcc0e3d..ca0041cc5b 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -434,6 +434,58 @@ async def test_uart_mock_modbus_server_controller_multiple( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_typed( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the typed modbus_client actions end to end (each action its own hub device). + + Start Scenario fires three typed actions: write_single_register puts 777 in server register 0x10 (the + ack fires on_response -> ack_flag); read_holding_registers reads it back, + with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); + a read of unserved register 0x99 resolves via on_error with the device's exception code + (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error + delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 + chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from + the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent + (-> not_sent_flag). + """ + + tracker = SensorTracker( + [ + "typed_value", + "ack_flag", + "error_code", + "coil_error_code", + "multi_value", + "multi_coil_error", + "not_sent_flag", + ] + ) + futures = tracker.expect_all( + { + "typed_value": 777, + "ack_flag": 1, + "error_code": 2, + "coil_error_code": 1, + "multi_value": 222, + "multi_coil_error": 1, + "not_sent_flag": 1, + } + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + + @pytest.mark.asyncio async def test_uart_mock_modbus_client_inline( yaml_config: str, From 23a34545512bda95450973acbc8d2f572eebfe13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 22:21:46 +0300 Subject: [PATCH 018/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 4: ruuvi_ble, ruuvitag, b_parasite) (#18161) --- esphome/components/b_parasite/b_parasite.cpp | 6 +-- esphome/components/b_parasite/b_parasite.h | 10 ++--- esphome/components/b_parasite/sensor.py | 13 ++++--- esphome/components/ruuvi_ble/__init__.py | 21 +++++----- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 10 ++--- esphome/components/ruuvi_ble/ruuvi_ble.h | 12 ++---- esphome/components/ruuvitag/ruuvitag.cpp | 4 -- esphome/components/ruuvitag/ruuvitag.h | 10 ++--- esphome/components/ruuvitag/sensor.py | 14 +++---- tests/components/b_parasite/common-ln.yaml | 7 ++++ tests/components/b_parasite/common.yaml | 3 ++ .../b_parasite/test.ln882x-ard.yaml | 3 ++ .../b_parasite/validate.bk72xx-ard.yaml | 26 +++++++++++++ tests/components/ruuvi_ble/common-ln.yaml | 1 + tests/components/ruuvi_ble/common.yaml | 3 ++ .../components/ruuvi_ble/test.ln882x-ard.yaml | 3 ++ .../ruuvi_ble/validate.bk72xx-ard.yaml | 9 +++++ tests/components/ruuvitag/common-ln.yaml | 7 ++++ tests/components/ruuvitag/common.yaml | 3 ++ .../components/ruuvitag/test.ln882x-ard.yaml | 3 ++ .../ruuvitag/validate.bk72xx-ard.yaml | 38 +++++++++++++++++++ 21 files changed, 146 insertions(+), 60 deletions(-) create mode 100644 tests/components/b_parasite/common-ln.yaml create mode 100644 tests/components/b_parasite/test.ln882x-ard.yaml create mode 100644 tests/components/b_parasite/validate.bk72xx-ard.yaml create mode 100644 tests/components/ruuvi_ble/common-ln.yaml create mode 100644 tests/components/ruuvi_ble/test.ln882x-ard.yaml create mode 100644 tests/components/ruuvi_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/ruuvitag/common-ln.yaml create mode 100644 tests/components/ruuvitag/test.ln882x-ard.yaml create mode 100644 tests/components/ruuvitag/validate.bk72xx-ard.yaml diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 160d22a5b6..e0ae824b0d 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,8 +1,6 @@ #include "b_parasite.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::b_parasite { static const char *const TAG = "b_parasite"; @@ -16,7 +14,7 @@ void BParasite::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index 1d5ac6e702..65540e82fb 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::b_parasite { -class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; } @@ -35,5 +33,3 @@ class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceL }; } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index 041303ad8b..673c5981b7 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, @@ -23,14 +23,15 @@ from esphome.const import ( CODEOWNERS = ["@rbaron"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] b_parasite_ns = cg.esphome_ns.namespace("b_parasite") BParasite = b_parasite_ns.class_( - "BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BParasite", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("b_parasite"), cv.Schema( { cv.GenerateID(): cv.declare_id(BParasite), @@ -68,15 +69,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/ruuvi_ble/__init__.py b/esphome/components/ruuvi_ble/__init__.py index 13d49d3cfe..8ab95dcb72 100644 --- a/esphome/components/ruuvi_ble/__init__.py +++ b/esphome/components/ruuvi_ble/__init__.py @@ -1,22 +1,25 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ruuvi_ble_ns = cg.esphome_ns.namespace("ruuvi_ble") RuuviListener = ruuvi_ble_ns.class_( - "RuuviListener", esp32_ble_tracker.ESPBTDeviceListener + "RuuviListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(RuuviListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ruuvi_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RuuviListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index b73b73d56e..19753b3c9e 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -1,13 +1,11 @@ #include "ruuvi_ble.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ruuvi_ble { static const char *const TAG = "ruuvi_ble"; -bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviParseResult &result) { +bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviParseResult &result) { const uint8_t data_type = adv_data[0]; const auto *data = &adv_data[1]; switch (data_type) { @@ -80,7 +78,7 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP return false; } } -optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device) { +optional parse_ruuvi(const ble_device_base::ESPBTDevice &device) { bool success = false; RuuviParseResult result{}; for (auto &it : device.get_manufacturer_datas()) { @@ -96,7 +94,7 @@ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &dev return result; } -bool RuuviListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool RuuviListener::parse_device(const ble_device_base::ESPBTDevice &device) { auto res = parse_ruuvi(device); if (!res.has_value()) return false; @@ -142,5 +140,3 @@ bool RuuviListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::ruuvi_ble - -#endif diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index e372b24944..d345790e3a 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -1,9 +1,7 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::ruuvi_ble { @@ -23,13 +21,11 @@ struct RuuviParseResult { bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_length, RuuviParseResult &result); -optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); +optional parse_ruuvi(const ble_device_base::ESPBTDevice &device); -class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::ruuvi_ble - -#endif diff --git a/esphome/components/ruuvitag/ruuvitag.cpp b/esphome/components/ruuvitag/ruuvitag.cpp index 99c6b8ae26..1536befb1b 100644 --- a/esphome/components/ruuvitag/ruuvitag.cpp +++ b/esphome/components/ruuvitag/ruuvitag.cpp @@ -1,8 +1,6 @@ #include "ruuvitag.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ruuvitag { static const char *const TAG = "ruuvitag"; @@ -23,5 +21,3 @@ void RuuviTag::dump_config() { } } // namespace esphome::ruuvitag - -#endif diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 9602b82afc..fc2d05a642 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ruuvi_ble/ruuvi_ble.h" -#ifdef USE_ESP32 - namespace esphome::ruuvitag { -class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (device.address_uint64() != this->address_) return false; @@ -77,5 +75,3 @@ class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceLi }; } // namespace esphome::ruuvitag - -#endif diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index af262b2950..e58d38ca84 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ACCELERATION, @@ -35,15 +35,15 @@ from esphome.const import ( UNIT_VOLT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["ruuvi_ble"] +AUTO_LOAD = ["ble_device_base", "ruuvi_ble"] ruuvitag_ns = cg.esphome_ns.namespace("ruuvitag") RuuviTag = ruuvitag_ns.class_( - "RuuviTag", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "RuuviTag", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ruuvitag"), cv.Schema( { cv.GenerateID(): cv.declare_id(RuuviTag), @@ -116,15 +116,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/tests/components/b_parasite/common-ln.yaml b/tests/components/b_parasite/common-ln.yaml new file mode 100644 index 0000000000..797b94c76f --- /dev/null +++ b/tests/components/b_parasite/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: b_parasite + mac_address: F0:CA:F0:CA:01:01 + humidity: + name: b-parasite Air Humidity + temperature: + name: b-parasite Air Temperature diff --git a/tests/components/b_parasite/common.yaml b/tests/components/b_parasite/common.yaml index 262e891bb2..e603d058b7 100644 --- a/tests/components/b_parasite/common.yaml +++ b/tests/components/b_parasite/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: b_parasite + ble_hub_id: ble_tracker_hub mac_address: F0:CA:F0:CA:01:01 humidity: name: b-parasite Air Humidity diff --git a/tests/components/b_parasite/test.ln882x-ard.yaml b/tests/components/b_parasite/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f711f63aa7 --- /dev/null +++ b/tests/components/b_parasite/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + b_parasite: !include common-ln.yaml diff --git a/tests/components/b_parasite/validate.bk72xx-ard.yaml b/tests/components/b_parasite/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fab4895d7b --- /dev/null +++ b/tests/components/b_parasite/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: b_parasite + ble_hub_id: ble_tracker_hub + mac_address: F0:CA:F0:CA:01:01 + humidity: + name: b-parasite Air Humidity + temperature: + name: b-parasite Air Temperature + moisture: + name: b-parasite Soil Moisture + battery_voltage: + name: b-parasite Battery Voltage + illuminance: + name: b-parasite Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: b_parasite + mac_address: F0:CA:F0:CA:01:02 + temperature: + name: BK b-parasite Implicit Temperature diff --git a/tests/components/ruuvi_ble/common-ln.yaml b/tests/components/ruuvi_ble/common-ln.yaml new file mode 100644 index 0000000000..39e578f349 --- /dev/null +++ b/tests/components/ruuvi_ble/common-ln.yaml @@ -0,0 +1 @@ +ruuvi_ble: diff --git a/tests/components/ruuvi_ble/common.yaml b/tests/components/ruuvi_ble/common.yaml index 1f155fd8e1..0221d865ef 100644 --- a/tests/components/ruuvi_ble/common.yaml +++ b/tests/components/ruuvi_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. ruuvi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/ruuvi_ble/test.ln882x-ard.yaml b/tests/components/ruuvi_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..5cc6a112ce --- /dev/null +++ b/tests/components/ruuvi_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ruuvi_ble: !include common-ln.yaml diff --git a/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml b/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c0436c0031 --- /dev/null +++ b/tests/components/ruuvi_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +ruuvi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/ruuvitag/common-ln.yaml b/tests/components/ruuvitag/common-ln.yaml new file mode 100644 index 0000000000..3219624340 --- /dev/null +++ b/tests/components/ruuvitag/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: ruuvitag + mac_address: FF:56:D3:2F:7D:E8 + humidity: + name: RuuviTag Humidity + temperature: + name: RuuviTag Temperature diff --git a/tests/components/ruuvitag/common.yaml b/tests/components/ruuvitag/common.yaml index 7990617710..ce6abf5bb5 100644 --- a/tests/components/ruuvitag/common.yaml +++ b/tests/components/ruuvitag/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: ruuvitag + ble_hub_id: ble_tracker_hub mac_address: FF:56:D3:2F:7D:E8 humidity: name: RuuviTag Humidity diff --git a/tests/components/ruuvitag/test.ln882x-ard.yaml b/tests/components/ruuvitag/test.ln882x-ard.yaml new file mode 100644 index 0000000000..9b0d8c2c58 --- /dev/null +++ b/tests/components/ruuvitag/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + ruuvitag: !include common-ln.yaml diff --git a/tests/components/ruuvitag/validate.bk72xx-ard.yaml b/tests/components/ruuvitag/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..967df8e53f --- /dev/null +++ b/tests/components/ruuvitag/validate.bk72xx-ard.yaml @@ -0,0 +1,38 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: ruuvitag + ble_hub_id: ble_tracker_hub + mac_address: FF:56:D3:2F:7D:E8 + humidity: + name: RuuviTag Humidity + temperature: + name: RuuviTag Temperature + pressure: + name: RuuviTag Pressure + acceleration: + name: RuuviTag Acceleration + acceleration_x: + name: RuuviTag Acceleration X + acceleration_y: + name: RuuviTag Acceleration Y + acceleration_z: + name: RuuviTag Acceleration Z + battery_voltage: + name: RuuviTag Battery Voltage + tx_power: + name: RuuviTag TX Power + movement_counter: + name: RuuviTag Movement Counter + measurement_sequence_number: + name: RuuviTag Measurement Sequence Number + # No ble_hub_id: exercises the generated binding real configs use. + - platform: ruuvitag + mac_address: FF:56:D3:2F:7D:E9 + temperature: + name: BK RuuviTag Implicit Temperature From 5d7bd179b162cf8dd3910472d48a1c5cf6585704 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:27:09 -0500 Subject: [PATCH 019/597] Bump github/codeql-action/analyze from 4.37.5 to 4.37.6 (#18163) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2751529222..aa71298d4d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{matrix.language}}" From ae26483be689c8dcd6c572db79e1a50cdbcb83e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:27:57 +0000 Subject: [PATCH 020/597] Bump github/codeql-action/init from 4.37.5 to 4.37.6 (#18164) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aa71298d4d..4e164cd9f6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 4a7d270494b158ff97d705f7b2518e3f65325a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Fri, 7 Aug 2026 23:46:43 +0300 Subject: [PATCH 021/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 5: airthings_ble, inkbird_ibsth1_mini, radon_eye_ble) (#18165) Co-authored-by: J. Nick Koston --- esphome/components/airthings_ble/__init__.py | 21 ++++++++++-------- .../airthings_ble/airthings_listener.cpp | 8 ++----- .../airthings_ble/airthings_listener.h | 10 +++------ .../inkbird_ibsth1_mini.cpp | 12 ++++------ .../inkbird_ibsth1_mini/inkbird_ibsth1_mini.h | 10 +++------ .../components/inkbird_ibsth1_mini/sensor.py | 13 ++++++----- esphome/components/radon_eye_ble/__init__.py | 21 ++++++++++-------- .../radon_eye_ble/radon_eye_listener.cpp | 6 +---- .../radon_eye_ble/radon_eye_listener.h | 10 +++------ tests/components/airthings_ble/common-ln.yaml | 1 + tests/components/airthings_ble/common.yaml | 6 +++++ .../airthings_ble/test.esp32-idf.yaml | 3 +++ .../airthings_ble/test.ln882x-ard.yaml | 3 +++ .../airthings_ble/validate.bk72xx-ard.yaml | 8 +++++++ .../inkbird_ibsth1_mini/common-ln.yaml | 7 ++++++ .../inkbird_ibsth1_mini/common.yaml | 3 +++ .../inkbird_ibsth1_mini/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 22 +++++++++++++++++++ tests/components/radon_eye_ble/common-ln.yaml | 1 + tests/components/radon_eye_ble/common.yaml | 3 +++ .../radon_eye_ble/test.ln882x-ard.yaml | 3 +++ .../radon_eye_ble/validate.bk72xx-ard.yaml | 9 ++++++++ 22 files changed, 119 insertions(+), 64 deletions(-) create mode 100644 tests/components/airthings_ble/common-ln.yaml create mode 100644 tests/components/airthings_ble/common.yaml create mode 100644 tests/components/airthings_ble/test.esp32-idf.yaml create mode 100644 tests/components/airthings_ble/test.ln882x-ard.yaml create mode 100644 tests/components/airthings_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/inkbird_ibsth1_mini/common-ln.yaml create mode 100644 tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml create mode 100644 tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml create mode 100644 tests/components/radon_eye_ble/common-ln.yaml create mode 100644 tests/components/radon_eye_ble/test.ln882x-ard.yaml create mode 100644 tests/components/radon_eye_ble/validate.bk72xx-ard.yaml diff --git a/esphome/components/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index 1545110798..d0cb7631d2 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -1,23 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeromelaban"] airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") AirthingsListener = airthings_ble_ns.class_( - "AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener + "AirthingsListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(AirthingsListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("airthings_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AirthingsListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 881b3e297b..f2625a7832 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -2,15 +2,13 @@ #include "esphome/core/log.h" #include -#ifdef USE_ESP32 - namespace esphome::airthings_ble { static const char *const TAG = "airthings_ble"; -bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) { for (auto &it : device.get_manufacturer_datas()) { - if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) { + if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) { if (it.data.size() < 4) continue; @@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 8105ac32eb..8fdfeb972f 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::airthings_ble { -class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index 4df22aa9de..d360142bcb 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -1,8 +1,6 @@ #include "inkbird_ibsth1_mini.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::inkbird_ibsth1_mini { static const char *const TAG = "inkbird_ibsth1_mini"; @@ -15,7 +13,7 @@ void InkbirdIbstH1Mini::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device) { // The below is based on my research and reverse engineering of a single device // It is entirely possible that some of that may be inaccurate or incomplete @@ -32,7 +30,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; } - if (device.get_address_type() != BLE_ADDR_TYPE_PUBLIC) { + if (device.get_address_type() != ble_device_base::BLE_ADDR_TYPE_PUBLIC) { ESP_LOGVV(TAG, "parse_device(): address is not public"); return false; } @@ -46,7 +44,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return false; } const auto &mnf_data = mnf_datas[0]; - if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) { + if (mnf_data.uuid.type() != ble_device_base::ESPBTUUID::Type::UUID16) { ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16"); return false; } @@ -71,7 +69,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic auto external_temperature = NAN; // Read bluetooth data into variable - auto measured_temperature = ((int16_t) mnf_data.uuid.get_uuid().uuid.uuid16) / 100.0f; + auto measured_temperature = ((int16_t) mnf_data.uuid.uuid16()) / 100.0f; // Set temperature or external_temperature based on which sensor is in use if (mnf_data.data[2] == 0) { @@ -104,5 +102,3 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 4c90d6d35b..726ea8c5ea 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -29,5 +27,3 @@ class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPB }; } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index b446c9f1e2..2dcdb9a118 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,14 +18,15 @@ from esphome.const import ( ) CODEOWNERS = ["@fkirill"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] inkbird_ibsth1_mini_ns = cg.esphome_ns.namespace("inkbird_ibsth1_mini") InkbirdIbstH1Mini = inkbird_ibsth1_mini_ns.class_( - "InkbirdIbstH1Mini", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "InkbirdIbstH1Mini", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("inkbird_ibsth1_mini"), cv.Schema( { cv.GenerateID(): cv.declare_id(InkbirdIbstH1Mini), @@ -57,15 +58,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/radon_eye_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py index 99daef30e5..2ba9d59d4c 100644 --- a/esphome/components/radon_eye_ble/__init__.py +++ b/esphome/components/radon_eye_ble/__init__.py @@ -1,23 +1,26 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeffeb3"] radon_eye_ble_ns = cg.esphome_ns.namespace("radon_eye_ble") RadonEyeListener = radon_eye_ble_ns.class_( - "RadonEyeListener", esp32_ble_tracker.ESPBTDeviceListener + "RadonEyeListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(RadonEyeListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("radon_eye_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RadonEyeListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 7e7263d73f..9ff279cab9 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -2,13 +2,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::radon_eye_ble { static const char *const TAG = "radon_eye_ble"; -bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool RadonEyeListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Radon Eye devices have names starting with "FR:" if (device.get_name().starts_with("FR:")) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -19,5 +17,3 @@ bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device } } // namespace esphome::radon_eye_ble - -#endif diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index 30e3ccc1ea..f9c8aa377d 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::radon_eye_ble { -class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::radon_eye_ble - -#endif diff --git a/tests/components/airthings_ble/common-ln.yaml b/tests/components/airthings_ble/common-ln.yaml new file mode 100644 index 0000000000..292192f052 --- /dev/null +++ b/tests/components/airthings_ble/common-ln.yaml @@ -0,0 +1 @@ +airthings_ble: diff --git a/tests/components/airthings_ble/common.yaml b/tests/components/airthings_ble/common.yaml new file mode 100644 index 0000000000..347f6640ad --- /dev/null +++ b/tests/components/airthings_ble/common.yaml @@ -0,0 +1,6 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +airthings_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/airthings_ble/test.esp32-idf.yaml b/tests/components/airthings_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..5883578909 --- /dev/null +++ b/tests/components/airthings_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + airthings_ble: !include common.yaml diff --git a/tests/components/airthings_ble/test.ln882x-ard.yaml b/tests/components/airthings_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..aa0ec6f7ab --- /dev/null +++ b/tests/components/airthings_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + airthings_ble: !include common-ln.yaml diff --git a/tests/components/airthings_ble/validate.bk72xx-ard.yaml b/tests/components/airthings_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..efcde0990e --- /dev/null +++ b/tests/components/airthings_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +airthings_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/inkbird_ibsth1_mini/common-ln.yaml b/tests/components/inkbird_ibsth1_mini/common-ln.yaml new file mode 100644 index 0000000000..618b4ff879 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: inkbird_ibsth1_mini + mac_address: 38:81:D7:0A:9C:11 + temperature: + name: Inkbird IBS-TH1 Temperature + humidity: + name: Inkbird IBS-TH1 Humidity diff --git a/tests/components/inkbird_ibsth1_mini/common.yaml b/tests/components/inkbird_ibsth1_mini/common.yaml index ba46b7dbf6..50c977cf8d 100644 --- a/tests/components/inkbird_ibsth1_mini/common.yaml +++ b/tests/components/inkbird_ibsth1_mini/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: inkbird_ibsth1_mini + ble_hub_id: ble_tracker_hub mac_address: 38:81:D7:0A:9C:11 temperature: name: Inkbird IBS-TH1 Temperature diff --git a/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml b/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml new file mode 100644 index 0000000000..2d37c8d318 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + inkbird_ibsth1_mini: !include common-ln.yaml diff --git a/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml b/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..93ea63c2a8 --- /dev/null +++ b/tests/components/inkbird_ibsth1_mini/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: inkbird_ibsth1_mini + ble_hub_id: ble_tracker_hub + mac_address: 38:81:D7:0A:9C:11 + temperature: + name: Inkbird IBS-TH1 Temperature + humidity: + name: Inkbird IBS-TH1 Humidity + battery_level: + name: Inkbird IBS-TH1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: inkbird_ibsth1_mini + mac_address: 38:81:D7:0A:9C:12 + temperature: + name: BK Inkbird Implicit Temperature diff --git a/tests/components/radon_eye_ble/common-ln.yaml b/tests/components/radon_eye_ble/common-ln.yaml new file mode 100644 index 0000000000..cfa30b967f --- /dev/null +++ b/tests/components/radon_eye_ble/common-ln.yaml @@ -0,0 +1 @@ +radon_eye_ble: diff --git a/tests/components/radon_eye_ble/common.yaml b/tests/components/radon_eye_ble/common.yaml index 85638d5c0e..4779f5db27 100644 --- a/tests/components/radon_eye_ble/common.yaml +++ b/tests/components/radon_eye_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. radon_eye_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/radon_eye_ble/test.ln882x-ard.yaml b/tests/components/radon_eye_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f32bca2a56 --- /dev/null +++ b/tests/components/radon_eye_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + radon_eye_ble: !include common-ln.yaml diff --git a/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml b/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..88e69921b5 --- /dev/null +++ b/tests/components/radon_eye_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +radon_eye_ble: + ble_hub_id: ble_tracker_hub From b0aec6dc2f2d9c3780f454d04f37a50742373326 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Fri, 7 Aug 2026 16:09:15 -0500 Subject: [PATCH 022/597] [modbus_client] Lambda sugar (#18146) Co-authored-by: Claude --- esphome/components/modbus_client/__init__.py | 33 ++++++++++++++ .../modbus_client/test_modbus_client.py | 44 ++++++++++++++++++- tests/components/modbus_client/common.yaml | 21 +++++++++ .../validate-autoload.esp32-idf.yaml | 16 +++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/components/modbus_client/validate-autoload.esp32-idf.yaml diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 538936c93b..52a61cacad 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_COUNT, + CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE, CONF_VALUE, @@ -17,6 +18,10 @@ from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +MULTI_CONF = True +# The modbus hub auto-loads this component to make the actions available. Without this, that auto-load +# would try to create a device with no address. +MULTI_CONF_NO_DEFAULT = True CONF_ON_CUSTOM_RESPONSE = "on_custom_response" CONF_ON_NO_RESPONSE = "on_no_response" @@ -67,6 +72,34 @@ _PDU_SPAN = cg.std_span.template(cg.uint8.operator("const")) # modbus.MAX_PDU_SIZE without reporting it, so an over-long lambda PDU is silently truncated. _PDU_BUFFER = modbus.modbus_ns.namespace("helpers").class_("PduBuffer") +# A bare modbus::ModbusClientDevice bound to a hub and a device address, and nothing else - no polling, +# no entities, no automation wiring. It exists so a lambda can talk to a device directly: +# +# modbus_client: +# - id: my_client +# address: 0x01 +# +# - lambda: "id(my_client).write_single_register(0x10, 42);" +# +# Nothing here overrides the device callbacks, so every outcome takes the base class default, and those +# are no-ops: a successful reply, a Modbus exception, a timeout, and a frame that never reached the wire +# are all discarded without a log. The single exception is a reply the dispatch gate treats as +# non-standard, which warns once per device and logs at VERBOSE after that. So a lambda gets no feedback +# on an ordinary failure - use the modbus_client.* actions whenever the outcome matters, since they carry +# on_response/on_error/on_no_response/on_not_sent handlers. +# The id is required, not generated: the device is reachable only through id() in a lambda, so an +# entry without one builds something nothing can name. Better to say so than to accept dead config. +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(modbus.ModbusClientDevice), + } +).extend(modbus.modbus_device_schema(None)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await modbus.register_modbus_client_device(var, config) + def _packed_bit_bytes(bits: int) -> int: """Mirrors modbus::packed_bit_bytes(): bytes needed to hold this many coils on the wire.""" diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index 10f9bc588e..7966048dd7 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,14 +7,16 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv +from esphome.components import modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, CONF_ON_SENT, CONF_PDU, + CONFIG_SCHEMA, MODBUS_CLIENT_SEND_SCHEMA, ) -from esphome.const import CONF_ADDRESS, CONF_ON_ERROR, CONF_ON_RESPONSE +from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE from esphome.core import Lambda from esphome.types import ConfigType @@ -114,3 +116,43 @@ def test_on_no_response_retry_lambda_accepted() -> None: }, } ) + + +# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin +# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other +# components rather than this one. + + +def test_component_requires_an_address() -> None: + """The address identifies the device on the bus, so there is no sensible default.""" + with pytest.raises(cv.Invalid, match=CONF_ADDRESS): + CONFIG_SCHEMA({CONF_ID: "bare_client"}) + + +def test_component_requires_an_id() -> None: + """The device is reachable only through id() in a lambda, so a generated id would be dead config.""" + with pytest.raises(cv.Invalid, match=CONF_ID): + CONFIG_SCHEMA({CONF_ADDRESS: 0x01}) + + +def test_component_accepts_an_id_and_address() -> None: + """modbus_id stays optional: it resolves to the single hub when only one is declared.""" + config = CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x01}) + assert config[CONF_ADDRESS] == 0x01 + + +def test_component_rejects_an_out_of_range_address() -> None: + """A Modbus device address is one byte.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x100}) + + +def test_multi_conf_no_default_is_set() -> None: + """Load-bearing: the modbus hub auto-loads this component to register its actions. + + Without MULTI_CONF_NO_DEFAULT that auto-load builds a default entry, which then fails the required + address above - breaking every configuration that uses modbus but never declares a modbus_client + block. validate-autoload.esp32-idf.yaml covers the same path end to end; this names the reason. + """ + assert modbus_client.MULTI_CONF is True + assert modbus_client.MULTI_CONF_NO_DEFAULT is True diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 19acbe9b0e..cae2002342 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -5,6 +5,18 @@ # device is retried forever. Reset the counter before the send or on a terminal outcome (on_response) # so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on # every retry, so the cap would never be reached. + +# The standalone component: a bare hub device with nothing but an address, so a lambda can drive the +# device directly. Two entries cover both hub-binding paths - the auto-resolved single hub and an +# explicit modbus_id - and the button below calls them, so the generated device has to be usable +# rather than merely constructed (an unreferenced one is optimised away entirely). +modbus_client: + - id: bare_client + address: 0x01 + - id: bare_client_explicit_hub + modbus_id: modbus_bus + address: 0x02 + globals: - id: read_retries type: int @@ -14,6 +26,15 @@ globals: initial_value: "0" button: + # The bare modbus_client devices: no handlers, so nothing reports the outcome - see the component + # comment. Calls here only pin that the device is bound to its hub and the helpers are reachable. + - platform: template + name: "Bare Client" + on_press: + - lambda: |- + id(bare_client).write_single_register(0x10, 42); + id(bare_client).write_single_coil(0x01, true); + id(bare_client_explicit_hub).read_holding_registers(0x20, 4); - platform: template name: "Send Read" on_press: diff --git a/tests/components/modbus_client/validate-autoload.esp32-idf.yaml b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml new file mode 100644 index 0000000000..318d492717 --- /dev/null +++ b/tests/components/modbus_client/validate-autoload.esp32-idf.yaml @@ -0,0 +1,16 @@ +# The modbus hub auto-loads modbus_client so the modbus_client.* actions are registered. That must not +# create a device on its own, which is what MULTI_CONF_NO_DEFAULT in the component buys: without it the +# auto-load builds a default entry and fails on the required address, breaking every modbus config. +# So this file deliberately declares no modbus_client: block - it is the no-block path, kept as its own +# fixture because common.yaml now declares one. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: "Action without a component block" + on_press: + - modbus_client.read_holding_registers: + address: 0x01 + start_address: 0x10 + count: 1 From 8d494a84c5ed505af37845df600065b91d981297 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2026 16:25:55 -0500 Subject: [PATCH 023/597] [bluetooth_connection] Engine follow-ups from the rp2 GATT series (#18159) --- .../bluetooth_connection_rp2.cpp | 149 +++++++++++++++--- .../bluetooth_connection_rp2.h | 16 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 3 + 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index ecd7a9713a..5eb3da0263 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -24,6 +24,8 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; +// Can-send windows normally open within a connection interval (tens of ms). +static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; // HCI "connection timeout" reason, reported when a teardown had to be forced. static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; @@ -176,11 +178,11 @@ void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); break; - case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: - con_handle = gatt_event_characteristic_value_query_result_get_handle(packet); + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_value_query_result_get_handle(packet); break; - case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: - con_handle = gatt_event_characteristic_descriptor_query_result_get_handle(packet); + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_descriptor_query_result_get_handle(packet); break; case GATT_EVENT_NOTIFICATION: con_handle = gatt_event_notification_get_handle(packet); @@ -263,24 +265,17 @@ void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *pa this->desc_count_++; break; } - case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: { - uint16_t len = gatt_event_characteristic_value_query_result_get_value_length(packet); - if (len > RP2_GATT_MAX_ATTR_LEN) { - len = RP2_GATT_MAX_ATTR_LEN; - } - memcpy(this->op_buffer_, gatt_event_characteristic_value_query_result_get_value(packet), len); - this->op_len_ = len; + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + // One blob per event at the reported offset; assemble into the op buffer. + this->assemble_blob_irq_(gatt_event_long_characteristic_value_query_result_get_value_offset(packet), + gatt_event_long_characteristic_value_query_result_get_value(packet), + gatt_event_long_characteristic_value_query_result_get_value_length(packet)); break; - } - case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: { - uint16_t len = gatt_event_characteristic_descriptor_query_result_get_descriptor_length(packet); - if (len > RP2_GATT_MAX_ATTR_LEN) { - len = RP2_GATT_MAX_ATTR_LEN; - } - memcpy(this->op_buffer_, gatt_event_characteristic_descriptor_query_result_get_descriptor(packet), len); - this->op_len_ = len; + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + this->assemble_blob_irq_(gatt_event_long_characteristic_descriptor_query_result_get_descriptor_offset(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor_length(packet)); break; - } case GATT_EVENT_NOTIFICATION: this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), gatt_event_notification_get_value(packet), @@ -297,28 +292,45 @@ void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *pa } // NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len) { + if (offset >= RP2_GATT_MAX_ATTR_LEN) { + return; + } + if (len > RP2_GATT_MAX_ATTR_LEN - offset) { + len = RP2_GATT_MAX_ATTR_LEN - offset; + } + memcpy(this->op_buffer_ + offset, data, len); + if (offset + len > this->op_len_) { + this->op_len_ = offset + len; + } +} + void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { RP2GattEvent *event = this->event_pool_.allocate(); if (event == nullptr) { this->event_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } event->type = type; event->status = status; event->value = value; this->event_queue_.push(event); + this->enable_loop_soon_any_context(); } void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { RP2GattNotifyEvent *event = this->notify_pool_.allocate(); if (event == nullptr) { this->notify_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } event->handle = handle; event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; memcpy(event->data, data, event->len); this->notify_queue_.push(event); + this->enable_loop_soon_any_context(); } // NOLINTEND(clang-analyzer-unix.Malloc) @@ -384,7 +396,27 @@ void RP2GattClient::loop() { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } - } else if (this->state_ == EngineState::IDLE) { + } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && + millis() - this->write_no_rsp_started_ > WRITE_NO_RSP_TIMEOUT_MS) { + // The can-send window never opened; report instead of hanging the op slot. + bool timed_out = false; + { + BluetoothLock lock; + // The trampoline may have just sent it; its queued result wins. + if (this->event_queue_.empty()) { + this->op_type_ = OpType::NONE; + timed_out = true; + } + } + if (timed_out) { + ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); + if (this->listener_ != nullptr) { + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); + } + } + } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && + this->event_queue_.empty() && this->notify_queue_.empty())) { + // Nothing pending: the enqueue path re-arms the loop from any context. this->disable_loop(); } } @@ -412,6 +444,35 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::QUERY_COMPLETE: this->handle_query_complete_(event.status); break; + case RP2GattEvent::WRITE_NO_RSP_DONE: + this->finish_write_no_rsp_(event.status); + break; + } +} + +void RP2GattClient::can_write_no_rsp_trampoline(void *context) { + // BTstack context: this callback IS the can-send window, so the deferred + // write happens here; only the result is enqueued for the main loop. + auto *self = static_cast(context); + if (self->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + uint8_t status = gatt_client_write_value_of_characteristic_without_response(self->con_handle_, self->op_handle_, + self->op_len_, self->op_buffer_); + if ((status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) && + gatt_client_request_to_write_without_response(&self->can_write_registration_, self->con_handle_) == 0) { + return; // next window retries; a failed re-arm falls through as an error + } + self->enqueue_event_irq_(RP2GattEvent::WRITE_NO_RSP_DONE, status, 0); +} + +void RP2GattClient::finish_write_no_rsp_(uint8_t status) { + if (this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + this->op_type_ = OpType::NONE; + if (this->listener_ != nullptr) { + this->listener_->on_write_result(this->op_handle_, status); } } @@ -514,7 +575,7 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate // from the query state machine). Completions with nothing in flight are // dropped below. - if (this->op_type_ != OpType::NONE) { + if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; if (this->listener_ == nullptr) { @@ -523,6 +584,13 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: + // A value that is an exact multiple of MTU - 1 ends with a trailing + // blob request some peers refuse with INVALID_OFFSET; the read is + // complete, not failed. + if ((att_status == ATT_ERROR_INVALID_OFFSET || att_status == ATT_ERROR_ATTRIBUTE_NOT_LONG) && + this->op_len_ > 0) { + att_status = 0; + } this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, att_status); break; @@ -822,8 +890,9 @@ int RP2GattClient::read_characteristic(uint16_t handle) { this->op_handle_ = handle; this->op_len_ = 0; BluetoothLock lock; - uint8_t status = gatt_client_read_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, - this->con_handle_, handle); + // Long variant: plain read first, blob continuations only past MTU - 1. + uint8_t status = gatt_client_read_long_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); if (status != 0) { this->op_type_ = OpType::NONE; return status; @@ -845,8 +914,38 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui uint8_t status; { BluetoothLock lock; + if (this->op_type_ == OpType::WRITE_CHAR_NO_RSP) { + // A deferred write is parked; sending now would overtake it. + return GATT_CLIENT_BUSY; + } status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, const_cast(data)); + // BTSTACK_ACL_BUFFERS_FULL is the same transient flow control one layer + // down (L2CAP), so it defers identically. + if (status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) { + if (this->op_in_flight_()) { + // The op buffer is owned; bounce the busy to the caller as before. + return status; + } + // Stash the payload and send from the can-send callback. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR_NO_RSP; + this->op_handle_ = handle; + this->op_len_ = len; + this->write_no_rsp_started_ = millis(); + this->can_write_registration_.callback = &RP2GattClient::can_write_no_rsp_trampoline; + this->can_write_registration_.context = this; + uint8_t req = gatt_client_request_to_write_without_response(&this->can_write_registration_, this->con_handle_); + if (req != 0 && req != ERROR_CODE_COMMAND_DISALLOWED) { + this->op_type_ = OpType::NONE; + return req; + } + // COMMAND_DISALLOWED = still armed from a timed-out deferral; that + // registration sends the newly parked payload. Keep the loop running + // so the deadline below can fire on a stalled link. + this->enable_loop(); + return 0; + } } if (status == 0 && this->listener_ != nullptr) { this->listener_->on_write_result(handle, 0); @@ -888,7 +987,7 @@ int RP2GattClient::read_descriptor(uint16_t handle) { this->op_handle_ = handle; this->op_len_ = 0; BluetoothLock lock; - uint8_t status = gatt_client_read_characteristic_descriptor_using_descriptor_handle( + uint8_t status = gatt_client_read_long_characteristic_descriptor_using_descriptor_handle( &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); if (status != 0) { this->op_type_ = OpType::NONE; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 145508bdf6..0c1bc95fe9 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -46,10 +46,11 @@ static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; // Control events from the BTstack handlers to loop(). struct RP2GattEvent { enum Type : uint8_t { - CONNECTED, // status + con_handle (value) - DISCONNECTED, // status = HCI reason - MTU_EXCHANGED, // value = negotiated MTU - QUERY_COMPLETE, // status = ATT status of the finished query + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + WRITE_NO_RSP_DONE, // status = result of the deferred write }; Type type; uint8_t status; @@ -106,7 +107,7 @@ class RP2GattClient final : public Component, enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; - enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, READ_DESC, WRITE_DESC }; + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, WRITE_CHAR_NO_RSP, READ_DESC, WRITE_DESC }; // The whole table in one transient allocation (RAMAllocator, checked), // freed after streaming. @@ -124,6 +125,7 @@ class RP2GattClient final : public Component, void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + void assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len); // Main-loop state machine. void handle_event_(const RP2GattEvent &event); @@ -137,6 +139,8 @@ class RP2GattClient final : public Component, void fail_connection_(uint8_t reason); void cleanup_link_state_(); bool notify_subscribed_(uint16_t handle) const; + static void can_write_no_rsp_trampoline(void *context); + void finish_write_no_rsp_(uint8_t status); void release_scan_inhibit_(); bool op_in_flight_() const { return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; @@ -156,10 +160,12 @@ class RP2GattClient final : public Component, // BTstack registrations gatt_client_notification_t notification_registration_{}; + btstack_context_callback_registration_t can_write_registration_{}; // Group 3: 4-byte types uint32_t connect_started_{0}; uint32_t disconnecting_started_{0}; + uint32_t write_no_rsp_started_{0}; // Group 4: 2-byte types (table counters written from the handler during // discovery, read from the main loop after the phase's QUERY_COMPLETE) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a25c9d9608..06e3b9a3b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -380,6 +380,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } else { this->send_device_pairing(msg.address, true); } + } else { + // Answer instead of leaving the client to time out. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } #else // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); From 1d184b43ebadbb34474daeae8b6854f048d292b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 04:47:52 +0300 Subject: [PATCH 024/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 6: thermopro_ble, exposure_notifications, xiaomi_ble) (#18168) --- .../exposure_notifications/__init__.py | 52 ++++++++++++++----- .../exposure_notifications.cpp | 6 +-- .../exposure_notifications.h | 10 ++-- esphome/components/thermopro_ble/sensor.py | 13 ++--- .../thermopro_ble/thermopro_ble.cpp | 10 ++-- .../components/thermopro_ble/thermopro_ble.h | 10 ++-- esphome/components/xiaomi_ble/__init__.py | 21 ++++---- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 41 +++++++-------- esphome/components/xiaomi_ble/xiaomi_ble.h | 12 ++--- .../ble_device_base/test_aes_ccm.cpp | 27 ++++++++++ .../exposure_notifications/common-ln.yaml | 5 ++ .../exposure_notifications/common.yaml | 3 ++ .../test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 15 ++++++ tests/components/thermopro_ble/common-ln.yaml | 7 +++ tests/components/thermopro_ble/common.yaml | 3 ++ .../thermopro_ble/test.ln882x-ard.yaml | 3 ++ .../thermopro_ble/validate.bk72xx-ard.yaml | 24 +++++++++ tests/components/xiaomi_ble/common-ln.yaml | 1 + tests/components/xiaomi_ble/common.yaml | 3 ++ .../xiaomi_ble/test.ln882x-ard.yaml | 3 ++ .../xiaomi_ble/validate.bk72xx-ard.yaml | 9 ++++ 22 files changed, 196 insertions(+), 85 deletions(-) create mode 100644 tests/components/exposure_notifications/common-ln.yaml create mode 100644 tests/components/exposure_notifications/test.ln882x-ard.yaml create mode 100644 tests/components/exposure_notifications/validate.bk72xx-ard.yaml create mode 100644 tests/components/thermopro_ble/common-ln.yaml create mode 100644 tests/components/thermopro_ble/test.ln882x-ard.yaml create mode 100644 tests/components/thermopro_ble/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_ble/common-ln.yaml create mode 100644 tests/components/xiaomi_ble/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_ble/validate.bk72xx-ard.yaml diff --git a/esphome/components/exposure_notifications/__init__.py b/esphome/components/exposure_notifications/__init__.py index ab7416a264..6cb5b750dd 100644 --- a/esphome/components/exposure_notifications/__init__.py +++ b/esphome/components/exposure_notifications/__init__.py @@ -1,33 +1,59 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_TRIGGER_ID +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications") ExposureNotification = exposure_notifications_ns.struct("ExposureNotification") ExposureNotificationTrigger = exposure_notifications_ns.class_( "ExposureNotificationTrigger", - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, automation.Trigger.template(ExposureNotification), ) CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification" +_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications") + +_VALIDATE_AUTOMATION = automation.validate_automation( + cv.Schema( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger), + } + # The trigger is the BLE listener, so the hub id lives on it. + ).extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +# validate_automation() needs a dict-based schema, so the rename cannot go +# inside it and has to run on the option value first. That value may also be a +# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so +# map over lists and let validate_automation() report anything else. +# schema_extractor keeps the key typed as a trigger in the generated editor +# schema; build_language_schema.py recurses into cv.All but not into a plain +# function. +@schema_extractor("automation") +def _validate_on_exposure_notification(value: Any) -> list[ConfigType]: + if value is SCHEMA_EXTRACT: + return _VALIDATE_AUTOMATION(value) + if isinstance(value, dict): + value = _RENAME_HUB_ID(value) + elif isinstance(value, list): + value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value] + return _VALIDATE_AUTOMATION(value) + + CONFIG_SCHEMA = cv.Schema( { - cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation( - cv.Schema( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ExposureNotificationTrigger - ), - } - ).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - ), + cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification, } ) @@ -36,4 +62,4 @@ async def to_code(config): for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) await automation.build_automation(trigger, [(ExposureNotification, "x")], conf) - await esp32_ble_tracker.register_ble_device(trigger, conf) + await ble_device_base.register_ble_device(trigger, conf) diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index e7038d2ca9..4f4b93b59c 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -2,11 +2,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { -using namespace esp32_ble_tracker; +using namespace ble_device_base; static const char *const TAG = "exposure_notifications"; @@ -43,5 +41,3 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { } } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 6a703a9a92..dc1241db56 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -2,11 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { struct ExposureNotification { @@ -17,11 +15,9 @@ struct ExposureNotification { }; class ExposureNotificationTrigger final : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index de63229621..d0d6cdacb7 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,14 +22,15 @@ from esphome.const import ( CODEOWNERS = ["@sittner"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble") ThermoProBLE = thermopro_ble_ns.class_( - "ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ThermoProBLE", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("thermopro_ble"), cv.Schema( { cv.GenerateID(): cv.declare_id(ThermoProBLE), @@ -68,15 +69,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 72e398f774..d10a6c33cd 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -2,8 +2,6 @@ #include #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::thermopro_ble { // this size must be large enough to hold the largest data frame @@ -34,7 +32,7 @@ void ThermoProBLE::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) { // check for matching mac address if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); @@ -66,8 +64,8 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } // reconstruct whole record from 2 byte uuid and data - esp_bt_uuid_t uuid = service_data.uuid.get_uuid(); - uint8_t data[MAX_DATA_SIZE] = {static_cast(uuid.uuid.uuid16), static_cast(uuid.uuid.uuid16 >> 8)}; + uint16_t svc_uuid16 = service_data.uuid.uuid16(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(svc_uuid16), static_cast(svc_uuid16 >> 8)}; std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); // dispatch data to parser @@ -202,5 +200,3 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz } } // namespace esphome::thermopro_ble - -#endif diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index ca04fbea39..1c05516e47 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -2,9 +2,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::thermopro_ble { @@ -17,11 +15,11 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -45,5 +43,3 @@ class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDevi }; } // namespace esphome::thermopro_ble - -#endif diff --git a/esphome/components/xiaomi_ble/__init__.py b/esphome/components/xiaomi_ble/__init__.py index 541a0e7894..7f5045f1ce 100644 --- a/esphome/components/xiaomi_ble/__init__.py +++ b/esphome/components/xiaomi_ble/__init__.py @@ -1,22 +1,25 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble") XiaomiListener = xiaomi_ble_ns.class_( - "XiaomiListener", esp32_ble_tracker.ESPBTDeviceListener + "XiaomiListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(XiaomiListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0961df2bd6..0a05950c5a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -2,14 +2,21 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - #include + +// AES-CCM backend for encrypted-payload (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code. +#ifdef USE_ESP32 #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define XIAOMI_CRYPTO_PSA +#endif +#endif +#ifndef XIAOMI_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::xiaomi_ble { @@ -166,7 +173,7 @@ bool parse_xiaomi_message(const std::vector &message, XiaomiParseResult return success; } -optional parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data) { +optional parse_xiaomi_header(const ble_device_base::ServiceData &service_data) { XiaomiParseResult result; if (!service_data.uuid.contains(0x95, 0xFE)) { ESP_LOGVV(TAG, "parse_xiaomi_header(): no service data UUID magic bytes."); @@ -318,7 +325,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1) memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef XIAOMI_CRYPTO_PSA // PSA AEAD expects ciphertext + tag concatenated uint8_t ct_with_tag[sizeof(vector.ciphertext) + sizeof(vector.tag)]; memcpy(ct_with_tag, vector.ciphertext, vector.datasize); @@ -344,20 +351,10 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c psa_destroy_key(key_id); bool decrypt_ok = (status == PSA_SUCCESS && plaintext_length == vector.datasize); #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, vector.key, vector.keysize * 8); - if (ret) { - ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, vector.datasize, vector.iv, vector.ivsize, vector.authdata, vector.authsize, - vector.ciphertext, vector.plaintext, vector.tag, vector.tagsize); - mbedtls_ccm_free(&ctx); - bool decrypt_ok = (ret == 0); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + bool decrypt_ok = ble_device_base::aes_ccm_auth_decrypt(vector.key, vector.iv, vector.ivsize, vector.authdata, + vector.authsize, vector.ciphertext, vector.datasize, + vector.plaintext, vector.tag, vector.tagsize); #endif if (!decrypt_ok) { @@ -448,7 +445,7 @@ bool report_xiaomi_results(const optional &result, const char return true; } -bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Previously the message was parsed twice per packet, once by XiaomiListener::parse_device() // and then again by the respective device class's parse_device() function. Parsing the header // here and then for each device seems to be unnecessary and complicates the duplicate packet filtering. @@ -460,5 +457,3 @@ bool XiaomiListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_ble - -#endif diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index 1ebcf0e2f5..2f3a14c150 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -1,12 +1,10 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/component.h" #include -#ifdef USE_ESP32 - namespace esphome::xiaomi_ble { struct XiaomiParseResult { @@ -68,15 +66,13 @@ struct XiaomiAESVector { bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_length, XiaomiParseResult &result); bool parse_xiaomi_message(const std::vector &message, XiaomiParseResult &result); -optional parse_xiaomi_header(const esp32_ble_tracker::ServiceData &service_data); +optional parse_xiaomi_header(const ble_device_base::ServiceData &service_data); bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::xiaomi_ble - -#endif diff --git a/tests/components/ble_device_base/test_aes_ccm.cpp b/tests/components/ble_device_base/test_aes_ccm.cpp index 39b2f81dcf..c844a30b2c 100644 --- a/tests/components/ble_device_base/test_aes_ccm.cpp +++ b/tests/components/ble_device_base/test_aes_ccm.cpp @@ -19,6 +19,33 @@ const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56}; const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01}; } // namespace +// Xiaomi's parameters differ from BTHome's: a 12-byte nonce and a 1-byte AAD +// (0x11). Both the AAD block and the l = 3 length encoding are only reachable +// through this shape, so they need their own vector. Generated the same way. +namespace { +const uint8_t NONCE_XIAOMI[12] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b}; +const uint8_t AAD_XIAOMI[1] = {0x11}; +const uint8_t CIPHERTEXT_XIAOMI[5] = {0xc3, 0x7e, 0x0a, 0x1d, 0x23}; +const uint8_t TAG_XIAOMI[4] = {0x98, 0x79, 0x87, 0xc6}; +const uint8_t PLAINTEXT_XIAOMI[5] = {0x04, 0x10, 0x02, 0xd4, 0x00}; +} // namespace + +TEST(BleAesCcm, DecryptsXiaomiShapedVector) { + uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {}; + EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), AAD_XIAOMI, sizeof(AAD_XIAOMI), + CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI))); + EXPECT_EQ(0, memcmp(out, PLAINTEXT_XIAOMI, sizeof(PLAINTEXT_XIAOMI))); +} + +TEST(BleAesCcm, RejectsWrongAssociatedData) { + uint8_t bad_aad[sizeof(AAD_XIAOMI)]; + memcpy(bad_aad, AAD_XIAOMI, sizeof(AAD_XIAOMI)); + bad_aad[0] ^= 0x01; + uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {}; + EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), bad_aad, sizeof(bad_aad), + CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI))); +} + TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) { uint8_t out[sizeof(PLAINTEXT)] = {}; EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG, diff --git a/tests/components/exposure_notifications/common-ln.yaml b/tests/components/exposure_notifications/common-ln.yaml new file mode 100644 index 0000000000..f3f9b93464 --- /dev/null +++ b/tests/components/exposure_notifications/common-ln.yaml @@ -0,0 +1,5 @@ +exposure_notifications: + on_exposure_notification: + then: + - lambda: | + ESP_LOGD("main", "RSSI: %d", x.rssi); diff --git a/tests/components/exposure_notifications/common.yaml b/tests/components/exposure_notifications/common.yaml index faba5bb2d1..8cc209ff4e 100644 --- a/tests/components/exposure_notifications/common.yaml +++ b/tests/components/exposure_notifications/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub exposure_notifications: on_exposure_notification: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + ble_hub_id: ble_tracker_hub then: - lambda: | ESP_LOGD("main", "Got notification:"); diff --git a/tests/components/exposure_notifications/test.ln882x-ard.yaml b/tests/components/exposure_notifications/test.ln882x-ard.yaml new file mode 100644 index 0000000000..964f5b68b0 --- /dev/null +++ b/tests/components/exposure_notifications/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + exposure_notifications: !include common-ln.yaml diff --git a/tests/components/exposure_notifications/validate.bk72xx-ard.yaml b/tests/components/exposure_notifications/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..b487591d95 --- /dev/null +++ b/tests/components/exposure_notifications/validate.bk72xx-ard.yaml @@ -0,0 +1,15 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +exposure_notifications: + on_exposure_notification: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + ble_hub_id: ble_tracker_hub + then: + - lambda: | + ESP_LOGD("main", "Got notification:"); + ESP_LOGD("main", " RPI: %s", format_hex(x.rolling_proximity_identifier).c_str()); + ESP_LOGD("main", " RSSI: %d", x.rssi); diff --git a/tests/components/thermopro_ble/common-ln.yaml b/tests/components/thermopro_ble/common-ln.yaml new file mode 100644 index 0000000000..10aff2d658 --- /dev/null +++ b/tests/components/thermopro_ble/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: ThermoPro Temperature + humidity: + name: ThermoPro Humidity diff --git a/tests/components/thermopro_ble/common.yaml b/tests/components/thermopro_ble/common.yaml index 297725e1c3..63fab83c01 100644 --- a/tests/components/thermopro_ble/common.yaml +++ b/tests/components/thermopro_ble/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: thermopro_ble + ble_hub_id: ble_tracker_hub mac_address: FE:74:B8:6A:97:B7 temperature: name: "ThermoPro Temperature" diff --git a/tests/components/thermopro_ble/test.ln882x-ard.yaml b/tests/components/thermopro_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..b3a59e83fc --- /dev/null +++ b/tests/components/thermopro_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + thermopro_ble: !include common-ln.yaml diff --git a/tests/components/thermopro_ble/validate.bk72xx-ard.yaml b/tests/components/thermopro_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..8f1ec417be --- /dev/null +++ b/tests/components/thermopro_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: thermopro_ble + ble_hub_id: ble_tracker_hub + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: "ThermoPro Temperature" + humidity: + name: "ThermoPro Humidity" + battery_level: + name: "ThermoPro Battery Level" + signal_strength: + name: "ThermoPro Signal Strength" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B8 + temperature: + name: BK ThermoPro Implicit Temperature diff --git a/tests/components/xiaomi_ble/common-ln.yaml b/tests/components/xiaomi_ble/common-ln.yaml new file mode 100644 index 0000000000..d46c306d65 --- /dev/null +++ b/tests/components/xiaomi_ble/common-ln.yaml @@ -0,0 +1 @@ +xiaomi_ble: diff --git a/tests/components/xiaomi_ble/common.yaml b/tests/components/xiaomi_ble/common.yaml index 9d10393177..f218426c23 100644 --- a/tests/components/xiaomi_ble/common.yaml +++ b/tests/components/xiaomi_ble/common.yaml @@ -1,3 +1,6 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. xiaomi_ble: + ble_hub_id: ble_tracker_hub diff --git a/tests/components/xiaomi_ble/test.ln882x-ard.yaml b/tests/components/xiaomi_ble/test.ln882x-ard.yaml new file mode 100644 index 0000000000..014985c5c5 --- /dev/null +++ b/tests/components/xiaomi_ble/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_ble: !include common-ln.yaml diff --git a/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml b/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..20b42c4263 --- /dev/null +++ b/tests/components/xiaomi_ble/validate.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +xiaomi_ble: + ble_hub_id: ble_tracker_hub From 3aaea907baa6ab6c095945410ec33d55c15162ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 05:59:21 +0300 Subject: [PATCH 025/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 7: xiaomi_cgd1, xiaomi_cgdk2, xiaomi_cgg1) (#18170) --- esphome/components/xiaomi_cgd1/sensor.py | 14 +++++------ .../components/xiaomi_cgd1/xiaomi_cgd1.cpp | 6 +---- esphome/components/xiaomi_cgd1/xiaomi_cgd1.h | 10 +++----- esphome/components/xiaomi_cgdk2/sensor.py | 20 ++++++++-------- .../components/xiaomi_cgdk2/xiaomi_cgdk2.cpp | 6 +---- .../components/xiaomi_cgdk2/xiaomi_cgdk2.h | 10 +++----- esphome/components/xiaomi_cgg1/sensor.py | 14 +++++------ .../components/xiaomi_cgg1/xiaomi_cgg1.cpp | 6 +---- esphome/components/xiaomi_cgg1/xiaomi_cgg1.h | 10 +++----- tests/components/xiaomi_cgd1/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_cgd1/common.yaml | 3 +++ .../xiaomi_cgd1/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgd1/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ tests/components/xiaomi_cgdk2/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_cgdk2/common.yaml | 9 ++++--- .../xiaomi_cgdk2/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgdk2/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ tests/components/xiaomi_cgg1/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_cgg1/common.yaml | 9 ++++--- .../xiaomi_cgg1/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgg1/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ 21 files changed, 162 insertions(+), 66 deletions(-) create mode 100644 tests/components/xiaomi_cgd1/common-ln.yaml create mode 100644 tests/components/xiaomi_cgd1/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_cgdk2/common-ln.yaml create mode 100644 tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_cgg1/common-ln.yaml create mode 100644 tests/components/xiaomi_cgg1/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index e11ddac19d..7206f023d7 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,15 +17,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_cgd1_ns = cg.esphome_ns.namespace("xiaomi_cgd1") XiaomiCGD1 = xiaomi_cgd1_ns.class_( - "XiaomiCGD1", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiCGD1", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgd1"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiCGD1), @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index 948e02be46..0159314f4d 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgd1 { static const char *const TAG = "xiaomi_cgd1"; @@ -21,7 +19,7 @@ void XiaomiCGD1::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGD1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGD1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGD1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGD1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgd1 - -#endif diff --git a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h index 1c510c7eb4..afa88738f0 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgd1 { -class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGD1 : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiCGD1 : public Component, public esp32_ble_tracker::ESPBTDeviceListen }; } // namespace esphome::xiaomi_cgd1 - -#endif diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index c7ec13f6e0..0e7535cd76 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,18 +17,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] -xiaomi_cgd1_ns = cg.esphome_ns.namespace("xiaomi_cgdk2") -XiaomiCGD1 = xiaomi_cgd1_ns.class_( - "XiaomiCGDK2", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +xiaomi_cgdk2_ns = cg.esphome_ns.namespace("xiaomi_cgdk2") +XiaomiCGDK2 = xiaomi_cgdk2_ns.class_( + "XiaomiCGDK2", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgdk2"), cv.Schema( { - cv.GenerateID(): cv.declare_id(XiaomiCGD1), + cv.GenerateID(): cv.declare_id(XiaomiCGDK2), cv.Required(CONF_BINDKEY): cv.bind_key, cv.Required(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index ff9036db14..01912c3778 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgdk2 { static const char *const TAG = "xiaomi_cgdk2"; @@ -21,7 +19,7 @@ void XiaomiCGDK2::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGDK2::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGDK2::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGDK2::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGDK2::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgdk2 - -#endif diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 36068ae227..a27d41cea5 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDevic }; } // namespace esphome::xiaomi_cgdk2 - -#endif diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 1a6ed2b7da..6273d8549b 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,15 +17,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_cgg1_ns = cg.esphome_ns.namespace("xiaomi_cgg1") XiaomiCGG1 = xiaomi_cgg1_ns.class_( - "XiaomiCGG1", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiCGG1", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgg1"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiCGG1), @@ -52,15 +52,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if CONF_BINDKEY in config: diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index ef4ef46424..679cb76198 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgg1 { static const char *const TAG = "xiaomi_cgg1"; @@ -21,7 +19,7 @@ void XiaomiCGG1::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiCGG1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGG1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiCGG1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGG1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgg1 - -#endif diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index 7633458cb8..ef666ab6d2 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDevice }; } // namespace esphome::xiaomi_cgg1 - -#endif diff --git a/tests/components/xiaomi_cgd1/common-ln.yaml b/tests/components/xiaomi_cgd1/common-ln.yaml new file mode 100644 index 0000000000..0ee92e3e14 --- /dev/null +++ b/tests/components/xiaomi_cgd1/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgd1 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGD1 Temperature + humidity: + name: Xiaomi CGD1 Humidity + battery_level: + name: Xiaomi CGD1 Battery Level diff --git a/tests/components/xiaomi_cgd1/common.yaml b/tests/components/xiaomi_cgd1/common.yaml index 94ed09e8f2..032a6d5c19 100644 --- a/tests/components/xiaomi_cgd1/common.yaml +++ b/tests/components/xiaomi_cgd1/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgd1 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: diff --git a/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..844f9c97bc --- /dev/null +++ b/tests/components/xiaomi_cgd1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgd1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..5980fba7ff --- /dev/null +++ b/tests/components/xiaomi_cgd1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgd1 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGD1 Temperature + humidity: + name: Xiaomi CGD1 Humidity + battery_level: + name: Xiaomi CGD1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgd1 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGD1 Implicit Temperature diff --git a/tests/components/xiaomi_cgdk2/common-ln.yaml b/tests/components/xiaomi_cgdk2/common-ln.yaml new file mode 100644 index 0000000000..f8ff21bd5b --- /dev/null +++ b/tests/components/xiaomi_cgdk2/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgdk2 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGDK2 Temperature + humidity: + name: Xiaomi CGDK2 Humidity + battery_level: + name: Xiaomi CGDK2 Battery Level diff --git a/tests/components/xiaomi_cgdk2/common.yaml b/tests/components/xiaomi_cgdk2/common.yaml index dddca56222..d5040aa0be 100644 --- a/tests/components/xiaomi_cgdk2/common.yaml +++ b/tests/components/xiaomi_cgdk2/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgdk2 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: - name: Xiaomi CGD1 Temperature + name: Xiaomi CGDK2 Temperature humidity: - name: Xiaomi CGD1 Humidity + name: Xiaomi CGDK2 Humidity battery_level: - name: Xiaomi CGD1 Battery Level + name: Xiaomi CGDK2 Battery Level diff --git a/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml b/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6f0fb03fd8 --- /dev/null +++ b/tests/components/xiaomi_cgdk2/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgdk2: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..0eb2617cd7 --- /dev/null +++ b/tests/components/xiaomi_cgdk2/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgdk2 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGDK2 Temperature + humidity: + name: Xiaomi CGDK2 Humidity + battery_level: + name: Xiaomi CGDK2 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgdk2 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGDK2 Implicit Temperature diff --git a/tests/components/xiaomi_cgg1/common-ln.yaml b/tests/components/xiaomi_cgg1/common-ln.yaml new file mode 100644 index 0000000000..f26d31ed50 --- /dev/null +++ b/tests/components/xiaomi_cgg1/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_cgg1 + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGG1 Temperature + humidity: + name: Xiaomi CGG1 Humidity + battery_level: + name: Xiaomi CGG1 Battery Level diff --git a/tests/components/xiaomi_cgg1/common.yaml b/tests/components/xiaomi_cgg1/common.yaml index 170aebfbde..e4a3ef4ba7 100644 --- a/tests/components/xiaomi_cgg1/common.yaml +++ b/tests/components/xiaomi_cgg1/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgg1 + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:D1:61:7D bindkey: c99d2313182473b38001086febf781bd temperature: - name: Xiaomi CGD1 Temperature + name: Xiaomi CGG1 Temperature humidity: - name: Xiaomi CGD1 Humidity + name: Xiaomi CGG1 Humidity battery_level: - name: Xiaomi CGD1 Battery Level + name: Xiaomi CGG1 Battery Level diff --git a/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..76ebbc01ed --- /dev/null +++ b/tests/components/xiaomi_cgg1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgg1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..11420925a0 --- /dev/null +++ b/tests/components/xiaomi_cgg1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgg1 + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:D1:61:7D + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: Xiaomi CGG1 Temperature + humidity: + name: Xiaomi CGG1 Humidity + battery_level: + name: Xiaomi CGG1 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgg1 + mac_address: A4:C1:38:D1:61:7E + bindkey: c99d2313182473b38001086febf781bd + temperature: + name: BK Xiaomi CGG1 Implicit Temperature From b986530efc4739c10f95d45f845877714db8a909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 06:51:33 +0300 Subject: [PATCH 026/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 8: xiaomi_cgpr1, xiaomi_gcls002, xiaomi_hhccjcy01) (#18171) --- .../components/xiaomi_cgpr1/binary_sensor.py | 12 ++++----- .../components/xiaomi_cgpr1/xiaomi_cgpr1.cpp | 6 +---- .../components/xiaomi_cgpr1/xiaomi_cgpr1.h | 10 +++---- esphome/components/xiaomi_gcls002/sensor.py | 14 +++++----- .../xiaomi_gcls002/xiaomi_gcls002.cpp | 6 +---- .../xiaomi_gcls002/xiaomi_gcls002.h | 10 +++---- esphome/components/xiaomi_hhccjcy01/sensor.py | 14 +++++----- .../xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp | 6 +---- .../xiaomi_hhccjcy01/xiaomi_hhccjcy01.h | 10 +++---- tests/components/xiaomi_cgpr1/common-ln.yaml | 11 ++++++++ tests/components/xiaomi_cgpr1/common.yaml | 3 +++ .../xiaomi_cgpr1/test.ln882x-ard.yaml | 3 +++ .../xiaomi_cgpr1/validate.bk72xx-ard.yaml | 24 +++++++++++++++++ .../components/xiaomi_gcls002/common-ln.yaml | 11 ++++++++ tests/components/xiaomi_gcls002/common.yaml | 3 +++ .../xiaomi_gcls002/test.ln882x-ard.yaml | 3 +++ .../xiaomi_gcls002/validate.bk72xx-ard.yaml | 24 +++++++++++++++++ .../xiaomi_hhccjcy01/common-ln.yaml | 13 ++++++++++ tests/components/xiaomi_hhccjcy01/common.yaml | 3 +++ .../xiaomi_hhccjcy01/test.ln882x-ard.yaml | 3 +++ .../xiaomi_hhccjcy01/validate.bk72xx-ard.yaml | 26 +++++++++++++++++++ 21 files changed, 159 insertions(+), 56 deletions(-) create mode 100644 tests/components/xiaomi_cgpr1/common-ln.yaml create mode 100644 tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_gcls002/common-ln.yaml create mode 100644 tests/components/xiaomi_gcls002/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_hhccjcy01/common-ln.yaml create mode 100644 tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_cgpr1/binary_sensor.py b/esphome/components/xiaomi_cgpr1/binary_sensor.py index 0606c93dbe..3fdcd983b0 100644 --- a/esphome/components/xiaomi_cgpr1/binary_sensor.py +++ b/esphome/components/xiaomi_cgpr1/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,18 +18,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_cgpr1_ns = cg.esphome_ns.namespace("xiaomi_cgpr1") XiaomiCGPR1 = xiaomi_cgpr1_ns.class_( "XiaomiCGPR1", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_cgpr1"), binary_sensor.binary_sensor_schema(XiaomiCGPR1, device_class=DEVICE_CLASS_MOTION) .extend( { @@ -57,15 +57,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index 3203f358b9..019de54ad8 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgpr1 { static const char *const TAG = "xiaomi_cgpr1"; @@ -16,7 +14,7 @@ void XiaomiCGPR1::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiCGPR1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiCGPR1::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -62,5 +60,3 @@ bool XiaomiCGPR1::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiCGPR1::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_cgpr1 - -#endif diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 0fa6c76e54..9e1d1c4482 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -3,21 +3,19 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_cgpr1 { class XiaomiCGPR1 final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_level(sensor::Sensor *battery_level) { battery_level_ = battery_level; } @@ -33,5 +31,3 @@ class XiaomiCGPR1 final : public Component, }; } // namespace esphome::xiaomi_cgpr1 - -#endif diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 6c9ad2e361..f430cbdd10 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, @@ -19,15 +19,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_gcls002_ns = cg.esphome_ns.namespace("xiaomi_gcls002") XiaomiGCLS002 = xiaomi_gcls002_ns.class_( - "XiaomiGCLS002", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiGCLS002", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_gcls002"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiGCLS002), @@ -58,15 +58,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 11ea98045b..27effd64bb 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -1,8 +1,6 @@ #include "xiaomi_gcls002.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_gcls002 { static const char *const TAG = "xiaomi_gcls002"; @@ -15,7 +13,7 @@ void XiaomiGCLS002::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiGCLS002::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -58,5 +56,3 @@ bool XiaomiGCLS002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_gcls002 - -#endif diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index 668133f364..969218c220 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_gcls002 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 90a8753412..2c2e88b75f 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,15 +22,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_hhccjcy01_ns = cg.esphome_ns.namespace("xiaomi_hhccjcy01") XiaomiHHCCJCY01 = xiaomi_hhccjcy01_ns.class_( - "XiaomiHHCCJCY01", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCJCY01", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccjcy01"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCJCY01), @@ -68,15 +68,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index 1d872c68c1..5e2369a6d9 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -1,8 +1,6 @@ #include "xiaomi_hhccjcy01.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy01 { static const char *const TAG = "xiaomi_hhccjcy01"; @@ -16,7 +14,7 @@ void XiaomiHHCCJCY01::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCJCY01::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -61,5 +59,3 @@ bool XiaomiHHCCJCY01::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_hhccjcy01 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index cb53b47f6f..ce573b73c1 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -32,5 +30,3 @@ class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_hhccjcy01 - -#endif diff --git a/tests/components/xiaomi_cgpr1/common-ln.yaml b/tests/components/xiaomi_cgpr1/common-ln.yaml new file mode 100644 index 0000000000..fa421b1eaa --- /dev/null +++ b/tests/components/xiaomi_cgpr1/common-ln.yaml @@ -0,0 +1,11 @@ +binary_sensor: + - platform: xiaomi_cgpr1 + name: CGPR1 Motion + mac_address: "12:34:56:12:34:56" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + battery_level: + name: CGPR1 battery Level + idle_time: + name: CGPR1 Idle Time + illuminance: + name: CGPR1 Illuminance diff --git a/tests/components/xiaomi_cgpr1/common.yaml b/tests/components/xiaomi_cgpr1/common.yaml index 48082a886c..ed59d31511 100644 --- a/tests/components/xiaomi_cgpr1/common.yaml +++ b/tests/components/xiaomi_cgpr1/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_cgpr1 + ble_hub_id: ble_tracker_hub name: CGPR1 Motion mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml b/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml new file mode 100644 index 0000000000..7199fd6a6c --- /dev/null +++ b/tests/components/xiaomi_cgpr1/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_cgpr1: !include common-ln.yaml diff --git a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..61c18a17cc --- /dev/null +++ b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_cgpr1 + ble_hub_id: ble_tracker_hub + name: CGPR1 Motion + mac_address: "12:34:56:12:34:56" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + battery_level: + name: CGPR1 battery Level + idle_time: + name: CGPR1 Idle Time + illuminance: + name: CGPR1 Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_cgpr1 + name: BK CGPR1 Implicit Motion + mac_address: "12:34:56:12:34:57" + bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_gcls002/common-ln.yaml b/tests/components/xiaomi_gcls002/common-ln.yaml new file mode 100644 index 0000000000..606f78e7cd --- /dev/null +++ b/tests/components/xiaomi_gcls002/common-ln.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: xiaomi_gcls002 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: GCLS02 Temperature + moisture: + name: GCLS02 Moisture + conductivity: + name: GCLS02 Soil Conductivity + illuminance: + name: GCLS02 Illuminance diff --git a/tests/components/xiaomi_gcls002/common.yaml b/tests/components/xiaomi_gcls002/common.yaml index 32990708cc..86ec068a19 100644 --- a/tests/components/xiaomi_gcls002/common.yaml +++ b/tests/components/xiaomi_gcls002/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_gcls002 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 temperature: name: GCLS02 Temperature diff --git a/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml b/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml new file mode 100644 index 0000000000..1bec7b000c --- /dev/null +++ b/tests/components/xiaomi_gcls002/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_gcls002: !include common-ln.yaml diff --git a/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml b/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f84d325685 --- /dev/null +++ b/tests/components/xiaomi_gcls002/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_gcls002 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: GCLS02 Temperature + moisture: + name: GCLS02 Moisture + conductivity: + name: GCLS02 Soil Conductivity + illuminance: + name: GCLS02 Illuminance + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_gcls002 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK GCLS02 Implicit Temperature diff --git a/tests/components/xiaomi_hhccjcy01/common-ln.yaml b/tests/components/xiaomi_hhccjcy01/common-ln.yaml new file mode 100644 index 0000000000..1fcbe985eb --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/common-ln.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: xiaomi_hhccjcy01 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY01 Temperature + moisture: + name: Xiaomi HHCCJCY01 Moisture + illuminance: + name: Xiaomi HHCCJCY01 Illuminance + conductivity: + name: Xiaomi HHCCJCY01 Soil Conductivity + battery_level: + name: Xiaomi HHCCJCY01 Battery Level diff --git a/tests/components/xiaomi_hhccjcy01/common.yaml b/tests/components/xiaomi_hhccjcy01/common.yaml index 0def909488..756f1280f6 100644 --- a/tests/components/xiaomi_hhccjcy01/common.yaml +++ b/tests/components/xiaomi_hhccjcy01/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_hhccjcy01 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 temperature: name: Xiaomi HHCCJCY01 Temperature diff --git a/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3cb949bb83 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccjcy01: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..da52b527e9 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy01/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy01 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY01 Temperature + moisture: + name: Xiaomi HHCCJCY01 Moisture + illuminance: + name: Xiaomi HHCCJCY01 Illuminance + conductivity: + name: Xiaomi HHCCJCY01 Soil Conductivity + battery_level: + name: Xiaomi HHCCJCY01 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccjcy01 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK Xiaomi HHCCJCY01 Implicit Temperature From 745dee0734d9233d01663335afca0d3c03f7d5f7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 7 Aug 2026 23:24:56 -0500 Subject: [PATCH 027/597] [usb_cdc_acm] Don't discard queued TX data when USB flush times out (#17637) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston --- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 17 +++ .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 132 +++++++++++++++--- 2 files changed, 131 insertions(+), 18 deletions(-) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 8e71fc61b2..d8eb91586a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,6 +7,7 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include #include "freertos/ringbuf.h" #include "tinyusb_cdc_acm.h" @@ -96,10 +97,26 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented rather than std::atomic because GCC on Xtensa + // generates an indirect function call for atomic ops instead of inlining + // them; atomic inlines correctly on all platforms. + std::atomic usb_tx_busy_{0}; + // Running total of bytes dropped by write_array() (never reset), and the timestamp + // of the last "buffer full" log line (throttled so a sustained host stall doesn't + // flood the log). + uint32_t tx_dropped_bytes_{0}; + uint32_t tx_dropped_log_ms_{0}; // RX buffer for peek functionality uint8_t peek_buffer_{0}; bool has_peek_{false}; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 859d6cbaea..e46369660d 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -2,6 +2,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -24,6 +25,13 @@ static constexpr size_t USB_CDC_MAX_LOG_BYTES = 168; static constexpr size_t USB_TX_TASK_STACK_SIZE = 4096; static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; +// Upper bound on how long flush() may block in total: the TX ring buffer drain and +// the final TinyUSB flush share this budget. +static constexpr uint32_t FLUSH_TIMEOUT_MS = 100; + +// Minimum interval between repeated warnings while a host stall persists. +static constexpr uint32_t LOG_THROTTLE_MS = 1000; + static USBCDCACMInstance *get_instance_by_itf(int itf) { if (global_usb_cdc_component == nullptr) { return nullptr; @@ -186,11 +194,21 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { void USBCDCACMInstance::usb_tx_task() { uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; size_t tx_data_size = 0; + // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs + // immediately (unsigned arithmetic keeps this wrap-safe). + uint32_t stall_log_ms = millis() - LOG_THROTTLE_MS; while (true) { + // Not holding any data while blocked waiting for more. + this->usb_tx_busy_ = 0; + // Wait for a notification from the bridge component ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + // Raise the busy flag before pulling data out of the ring buffer, so at every + // instant flush() sees pending bytes in the ring buffer count or in this flag. + this->usb_tx_busy_ = 1; + // When we do wake up, we can be sure there is data in the ring buffer esp_err_t ret = ringbuf_read_bytes(this->usb_tx_ringbuf_, data, CONFIG_TINYUSB_CDC_TX_BUFSIZE, &tx_data_size, 0); @@ -224,11 +242,50 @@ void USBCDCACMInstance::usb_tx_task() { esp_err_t flush_ret = tinyusb_cdcacm_write_flush(static_cast(this->itf_), pdMS_TO_TICKS(10)); - if (flush_ret != ESP_OK) { - ESP_LOGE(TAG, "USB TX itf=%d: flush failed", this->itf_); - tud_cdc_n_write_clear(this->itf_); - break; + if (flush_ret == ESP_OK) { + continue; } + + // Bytes not yet handed to TinyUSB plus bytes still sitting in its transmit FIFO. + // tud_cdc_n_write_occupied() is not public API in the pinned TinyUSB release, so + // derive the occupancy from the FIFO depth TinyUSB itself is configured with. + const size_t pending = tx_data_size + (CFG_TUD_CDC_TX_BUFSIZE - tud_cdc_n_write_available(this->itf_)); + + // A flush timeout only means TinyUSB's transmit FIFO did not fully drain within + // the wait window; the queued bytes are untouched and TinyUSB keeps sending them + // from its transfer-complete callback once the host polls again. Clearing the + // FIFO here would discard the tail of a frame whose head is already on the wire, + // corrupting the stream mid-frame. Hold the data and retry instead; sustained + // backpressure then propagates to the ring buffer, which drops whole writes with + // a warning instead of splitting a frame. + // + // Gate the retry on DTR (tud_cdc_n_connected()) rather than tud_ready(): an + // enumerated-but-idle host (no application holding the port open) never polls + // the IN endpoint, so retrying on tud_ready() alone would wedge this task -- and + // stall every write_array()/flush() caller behind a full ring buffer -- for as + // long as the board sits plugged into an idle PC. DTR means an application has + // the port open and is expected to eventually read. + if (flush_ret == ESP_ERR_TIMEOUT && tud_cdc_n_connected(this->itf_)) { + const uint32_t now = millis(); + if ((now - stall_log_ms) >= LOG_THROTTLE_MS) { + stall_log_ms = now; + ESP_LOGW(TAG, "USB TX itf=%d: host not reading; %zu bytes pending", this->itf_, pending); + } + continue; + } + + if (flush_ret == ESP_ERR_TIMEOUT) { + // No application has the port open (DTR deasserted) or the device is detached, + // so the data cannot be delivered. TinyUSB does not clear its transmit FIFO on + // bus reset; drop the data here so a stale partial frame is not replayed when + // the port is (re)opened. + ESP_LOGW(TAG, "USB TX itf=%d: not connected; dropping %zu bytes", this->itf_, pending); + } else { + ESP_LOGE(TAG, "USB TX itf=%d: flush failed (%s); dropping %zu bytes", this->itf_, esp_err_to_name(flush_ret), + pending); + } + tud_cdc_n_write_clear(this->itf_); + break; } } } @@ -245,7 +302,19 @@ void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { // Write data to TX ring buffer BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); if (send_res != pdTRUE) { - ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %u bytes dropped", this->itf_, len); + // During a sustained host stall the ring buffer stays full (that is the intended + // backpressure), so this path runs for every write; throttle the warning so the + // log stays readable. The counter is a running total that is never reset: each + // line reports all bytes dropped so far, so bytes dropped in the tail of one + // stall are still accounted for by the next line, whenever that is. It also makes + // the very first drop since boot detectable, which is logged unthrottled. + const bool first_drop = this->tx_dropped_bytes_ == 0; + this->tx_dropped_bytes_ += len; + const uint32_t now = millis(); + if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { + this->tx_dropped_log_ms_ = now; + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %" PRIu32 " bytes dropped total", this->itf_, this->tx_dropped_bytes_); + } return; } @@ -326,27 +395,54 @@ size_t USBCDCACMInstance::available() { return waiting + (this->has_peek_ ? 1 : 0); } +// True while TX bytes have not yet reached TinyUSB's FIFO: still counted in the ring +// buffer, or held by the TX task (usb_tx_busy_) between pulling them from the ring +// buffer and handing them to TinyUSB -- there they are in neither the ring buffer +// count nor TinyUSB's FIFO. +bool USBCDCACMInstance::tx_pending_() { + UBaseType_t waiting = 0; + vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + return waiting != 0 || this->usb_tx_busy_ != 0; +} + uart::UARTFlushResult USBCDCACMInstance::flush() { - // Wait for TX ring buffer to be empty if (this->usb_tx_ringbuf_ == nullptr) { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } - UBaseType_t waiting = 1; - while (waiting > 0) { - vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); - if (waiting > 0) { - vTaskDelay(pdMS_TO_TICKS(1)); + // Bound the wait: when the host stalls or disconnects, the TX task holds on to + // pending data rather than discarding it, so the ring buffer may not drain for as + // long as the host stays away. flush() runs on the caller's (typically the main + // loop) task and must not block indefinitely. Signed tick differences keep the + // deadline arithmetic wrap-safe. + TickType_t now = xTaskGetTickCount(); + const TickType_t deadline = now + pdMS_TO_TICKS(FLUSH_TIMEOUT_MS); + while (this->tx_pending_()) { + if (static_cast(now - deadline) >= 0) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; } + vTaskDelay(pdMS_TO_TICKS(1)); + now = xTaskGetTickCount(); } - // Also wait for USB to finish transmitting - esp_err_t err = tinyusb_cdcacm_write_flush(static_cast(this->itf_), pdMS_TO_TICKS(100)); - if (err == ESP_OK) - return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; - if (err == ESP_ERR_TIMEOUT) - return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; - return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + // Also wait for USB to finish transmitting, within whatever remains of the budget. + // Floor at one tick: a zero-tick timeout takes esp_tinyusb's non-blocking branch, + // whose return contract is that library's internal detail and may differ between + // releases. One tick keeps the call on the blocking branch (ESP_OK/ESP_ERR_TIMEOUT) + // at the cost of at most one tick over budget. + const int32_t remaining = static_cast(deadline - now); + const TickType_t flush_ticks = remaining > 0 ? static_cast(remaining) : 1; + switch (tinyusb_cdcacm_write_flush(static_cast(this->itf_), flush_ticks)) { + case ESP_OK: + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + case ESP_ERR_TIMEOUT: + // ESP_ERR_NOT_FINISHED is the non-blocking branch's "still draining" result; + // mapped like a timeout in case a future esp_tinyusb release returns it here. + case ESP_ERR_NOT_FINISHED: + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + default: + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } } void USBCDCACMInstance::check_logger_conflict() {} From 087b80eeb51857bb28ca1b1e96863281ccdab72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 07:42:37 +0300 Subject: [PATCH 028/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 9: xiaomi_hhccjcy10, xiaomi_hhccpot002, xiaomi_jqjcy01ym) (#18172) --- esphome/components/xiaomi_hhccjcy10/sensor.py | 13 +++++----- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 6 +---- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.h | 10 +++---- .../components/xiaomi_hhccpot002/sensor.py | 14 +++++----- .../xiaomi_hhccpot002/xiaomi_hhccpot002.cpp | 6 +---- .../xiaomi_hhccpot002/xiaomi_hhccpot002.h | 10 +++---- esphome/components/xiaomi_jqjcy01ym/sensor.py | 14 +++++----- .../xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp | 6 +---- .../xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h | 10 +++---- tests/components/xiaomi_cgpr1/common-ln.yaml | 2 +- tests/components/xiaomi_cgpr1/common.yaml | 2 +- .../xiaomi_cgpr1/validate.bk72xx-ard.yaml | 2 +- .../xiaomi_hhccjcy10/common-ln.yaml | 13 ++++++++++ tests/components/xiaomi_hhccjcy10/common.yaml | 18 +++++++++++++ .../xiaomi_hhccjcy10/test.esp32-idf.yaml | 3 +++ .../xiaomi_hhccjcy10/test.ln882x-ard.yaml | 3 +++ .../xiaomi_hhccjcy10/validate.bk72xx-ard.yaml | 26 +++++++++++++++++++ .../xiaomi_hhccpot002/common-ln.yaml | 7 +++++ .../components/xiaomi_hhccpot002/common.yaml | 3 +++ .../xiaomi_hhccpot002/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 20 ++++++++++++++ .../xiaomi_jqjcy01ym/common-ln.yaml | 11 ++++++++ tests/components/xiaomi_jqjcy01ym/common.yaml | 3 +++ .../xiaomi_jqjcy01ym/test.ln882x-ard.yaml | 3 +++ .../xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml | 24 +++++++++++++++++ 25 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 tests/components/xiaomi_hhccjcy10/common-ln.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/common.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_hhccpot002/common-ln.yaml create mode 100644 tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_jqjcy01ym/common-ln.yaml create mode 100644 tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index d6a4a4adb2..56eeda484e 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -22,14 +22,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_hhccjcy10_ns = cg.esphome_ns.namespace("xiaomi_hhccjcy10") XiaomiHHCCJCY10 = xiaomi_hhccjcy10_ns.class_( - "XiaomiHHCCJCY10", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCJCY10", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccjcy10"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCJCY10), @@ -67,15 +68,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index c6ebd5ff74..680eb04e77 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccjcy10 { static const char *const TAG = "xiaomi_hhccjcy10"; @@ -17,7 +15,7 @@ void XiaomiHHCCJCY10::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -63,5 +61,3 @@ bool XiaomiHHCCJCY10::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_hhccjcy10 - -#endif diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index fa2f461534..ce6dc2081e 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -31,5 +29,3 @@ class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_hhccjcy10 - -#endif diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index adc64f6650..50b10777bb 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, @@ -13,15 +13,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_hhccpot002_ns = cg.esphome_ns.namespace("xiaomi_hhccpot002") XiaomiHHCCPOT002 = xiaomi_hhccpot002_ns.class_( - "XiaomiHHCCPOT002", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiHHCCPOT002", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_hhccpot002"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiHHCCPOT002), @@ -40,15 +40,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index bbca9faaa6..fc8d15228d 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -1,8 +1,6 @@ #include "xiaomi_hhccpot002.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccpot002 { static const char *const TAG = "xiaomi_hhccpot002"; @@ -13,7 +11,7 @@ void XiaomiHHCCPOT002 ::dump_config() { LOG_SENSOR(" ", "Conductivity", this->conductivity_); } -bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiHHCCPOT002::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,5 +50,3 @@ bool XiaomiHHCCPOT002::parse_device(const esp32_ble_tracker::ESPBTDevice &device } } // namespace esphome::xiaomi_hhccpot002 - -#endif diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 3eda1b9859..e472178baa 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_moisture(sensor::Sensor *moisture) { moisture_ = moisture; } @@ -26,5 +24,3 @@ class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_hhccpot002 - -#endif diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index 5890ed6b63..7467f08785 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -19,15 +19,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_jqjcy01ym_ns = cg.esphome_ns.namespace("xiaomi_jqjcy01ym") XiaomiJQJCY01YM = xiaomi_jqjcy01ym_ns.class_( - "XiaomiJQJCY01YM", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiJQJCY01YM", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_jqjcy01ym"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiJQJCY01YM), @@ -59,15 +59,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index c0f4de3d06..f7a1318d7c 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -1,8 +1,6 @@ #include "xiaomi_jqjcy01ym.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_jqjcy01ym { static const char *const TAG = "xiaomi_jqjcy01ym"; @@ -15,7 +13,7 @@ void XiaomiJQJCY01YM::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiJQJCY01YM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -58,5 +56,3 @@ bool XiaomiJQJCY01YM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_jqjcy01ym - -#endif diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index 122c6776c9..955ee41880 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_jqjcy01ym - -#endif diff --git a/tests/components/xiaomi_cgpr1/common-ln.yaml b/tests/components/xiaomi_cgpr1/common-ln.yaml index fa421b1eaa..675d7ac18e 100644 --- a/tests/components/xiaomi_cgpr1/common-ln.yaml +++ b/tests/components/xiaomi_cgpr1/common-ln.yaml @@ -4,7 +4,7 @@ binary_sensor: mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_cgpr1/common.yaml b/tests/components/xiaomi_cgpr1/common.yaml index ed59d31511..d713e5e996 100644 --- a/tests/components/xiaomi_cgpr1/common.yaml +++ b/tests/components/xiaomi_cgpr1/common.yaml @@ -9,7 +9,7 @@ binary_sensor: mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml index 61c18a17cc..749adfebe2 100644 --- a/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml +++ b/tests/components/xiaomi_cgpr1/validate.bk72xx-ard.yaml @@ -12,7 +12,7 @@ binary_sensor: mac_address: "12:34:56:12:34:56" bindkey: 48403ebe2d385db8d0c187f81e62cb64 battery_level: - name: CGPR1 battery Level + name: CGPR1 Battery Level idle_time: name: CGPR1 Idle Time illuminance: diff --git a/tests/components/xiaomi_hhccjcy10/common-ln.yaml b/tests/components/xiaomi_hhccjcy10/common-ln.yaml new file mode 100644 index 0000000000..c71b5cc1e7 --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/common-ln.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: xiaomi_hhccjcy10 + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level diff --git a/tests/components/xiaomi_hhccjcy10/common.yaml b/tests/components/xiaomi_hhccjcy10/common.yaml new file mode 100644 index 0000000000..79efdde42d --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/common.yaml @@ -0,0 +1,18 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy10 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level diff --git a/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml b/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml new file mode 100644 index 0000000000..bc67f843ff --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_hhccjcy10: !include common.yaml diff --git a/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml new file mode 100644 index 0000000000..8fe9e74dfd --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccjcy10: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e39bcfb8be --- /dev/null +++ b/tests/components/xiaomi_hhccjcy10/validate.bk72xx-ard.yaml @@ -0,0 +1,26 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccjcy10 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + temperature: + name: Xiaomi HHCCJCY10 Temperature + moisture: + name: Xiaomi HHCCJCY10 Moisture + illuminance: + name: Xiaomi HHCCJCY10 Illuminance + conductivity: + name: Xiaomi HHCCJCY10 Conductivity + battery_level: + name: Xiaomi HHCCJCY10 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccjcy10 + mac_address: 94:2B:FF:5C:91:62 + temperature: + name: BK Xiaomi HHCCJCY10 Implicit Temperature diff --git a/tests/components/xiaomi_hhccpot002/common-ln.yaml b/tests/components/xiaomi_hhccpot002/common-ln.yaml new file mode 100644 index 0000000000..6f39b6a2b8 --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: xiaomi_hhccpot002 + mac_address: 94:2B:FF:5C:91:61 + moisture: + name: HHCCPOT002 Moisture + conductivity: + name: HHCCPOT002 Soil Conductivity diff --git a/tests/components/xiaomi_hhccpot002/common.yaml b/tests/components/xiaomi_hhccpot002/common.yaml index 2e5fa14620..cee426f100 100644 --- a/tests/components/xiaomi_hhccpot002/common.yaml +++ b/tests/components/xiaomi_hhccpot002/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_hhccpot002 + ble_hub_id: ble_tracker_hub mac_address: 94:2B:FF:5C:91:61 moisture: name: HHCCPOT002 Moisture diff --git a/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml b/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml new file mode 100644 index 0000000000..1f69281400 --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_hhccpot002: !include common-ln.yaml diff --git a/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml b/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c4003ecf4b --- /dev/null +++ b/tests/components/xiaomi_hhccpot002/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_hhccpot002 + ble_hub_id: ble_tracker_hub + mac_address: 94:2B:FF:5C:91:61 + moisture: + name: HHCCPOT002 Moisture + conductivity: + name: HHCCPOT002 Soil Conductivity + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_hhccpot002 + mac_address: 94:2B:FF:5C:91:62 + moisture: + name: BK HHCCPOT002 Implicit Moisture diff --git a/tests/components/xiaomi_jqjcy01ym/common-ln.yaml b/tests/components/xiaomi_jqjcy01ym/common-ln.yaml new file mode 100644 index 0000000000..c20269eab4 --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/common-ln.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: xiaomi_jqjcy01ym + mac_address: 7A:80:8E:19:36:BA + temperature: + name: JQJCY01YM Temperature + humidity: + name: JQJCY01YM Humidity + formaldehyde: + name: JQJCY01YM Formaldehyde + battery_level: + name: JQJCY01YM Battery Level diff --git a/tests/components/xiaomi_jqjcy01ym/common.yaml b/tests/components/xiaomi_jqjcy01ym/common.yaml index 54c4b33dcd..1aace227cf 100644 --- a/tests/components/xiaomi_jqjcy01ym/common.yaml +++ b/tests/components/xiaomi_jqjcy01ym/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_jqjcy01ym + ble_hub_id: ble_tracker_hub mac_address: 7A:80:8E:19:36:BA temperature: name: JQJCY01YM Temperature diff --git a/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml b/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml new file mode 100644 index 0000000000..f3196e5188 --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_jqjcy01ym: !include common-ln.yaml diff --git a/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml b/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..c63ddcae3e --- /dev/null +++ b/tests/components/xiaomi_jqjcy01ym/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_jqjcy01ym + ble_hub_id: ble_tracker_hub + mac_address: 7A:80:8E:19:36:BA + temperature: + name: JQJCY01YM Temperature + humidity: + name: JQJCY01YM Humidity + formaldehyde: + name: JQJCY01YM Formaldehyde + battery_level: + name: JQJCY01YM Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_jqjcy01ym + mac_address: 7A:80:8E:19:36:BB + temperature: + name: BK JQJCY01YM Implicit Temperature From 413a4c5885ae762d1e7814a7274f6109908c1c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 08:27:44 +0300 Subject: [PATCH 029/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 10: xiaomi_lywsd02, xiaomi_lywsd02mmc, xiaomi_lywsd03mmc) (#18174) --- esphome/components/xiaomi_lywsd02/sensor.py | 14 +++++------ .../xiaomi_lywsd02/xiaomi_lywsd02.cpp | 6 +---- .../xiaomi_lywsd02/xiaomi_lywsd02.h | 10 +++----- .../components/xiaomi_lywsd02mmc/sensor.py | 14 +++++------ .../xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp | 6 +---- .../xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h | 10 +++----- .../components/xiaomi_lywsd03mmc/sensor.py | 14 +++++------ .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp | 6 +---- .../xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h | 10 +++----- .../components/xiaomi_lywsd02/common-ln.yaml | 9 +++++++ tests/components/xiaomi_lywsd02/common.yaml | 3 +++ .../xiaomi_lywsd02/test.ln882x-ard.yaml | 3 +++ .../xiaomi_lywsd02/validate.bk72xx-ard.yaml | 22 +++++++++++++++++ .../xiaomi_lywsd02mmc/common-ln.yaml | 10 ++++++++ .../components/xiaomi_lywsd02mmc/common.yaml | 3 +++ .../xiaomi_lywsd02mmc/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ .../xiaomi_lywsd03mmc/common-ln.yaml | 10 ++++++++ .../components/xiaomi_lywsd03mmc/common.yaml | 3 +++ .../xiaomi_lywsd03mmc/test.ln882x-ard.yaml | 3 +++ .../validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ 21 files changed, 150 insertions(+), 57 deletions(-) create mode 100644 tests/components/xiaomi_lywsd02/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_lywsd02mmc/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_lywsd03mmc/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index ef6aebe6c0..c455961e7e 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsd02_ns = cg.esphome_ns.namespace("xiaomi_lywsd02") XiaomiLYWSD02 = xiaomi_lywsd02_ns.class_( - "XiaomiLYWSD02", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD02", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd02"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD02), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index 75909738c8..d465f2fec0 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -1,8 +1,6 @@ #include "xiaomi_lywsd02.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02 { static const char *const TAG = "xiaomi_lywsd02"; @@ -14,7 +12,7 @@ void XiaomiLYWSD02::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD02::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiLYWSD02::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_lywsd02 - -#endif diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index 09256047ae..0c1035bf1d 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_lywsd02 - -#endif diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 813429a6c5..000460b333 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,16 +17,16 @@ from esphome.const import ( UNIT_PERCENT, ) -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@juanluss31"] -DEPENDENCIES = ["esp32_ble_tracker"] xiaomi_lywsd02mmc_ns = cg.esphome_ns.namespace("xiaomi_lywsd02mmc") XiaomiLYWSD02MMC = xiaomi_lywsd02mmc_ns.class_( - "XiaomiLYWSD02MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD02MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd02mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD02MMC), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index 79610ee266..dca5f73909 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02mmc { static const char *const TAG = "xiaomi_lywsd02mmc"; @@ -21,7 +19,7 @@ void XiaomiLYWSD02MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD02MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD02MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiLYWSD02MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device void XiaomiLYWSD02MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_lywsd02mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index efd758b972..e00afffe0a 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_lywsd02mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index bf2de3756c..6362f26524 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -19,15 +19,15 @@ from esphome.const import ( CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsd03mmc_ns = cg.esphome_ns.namespace("xiaomi_lywsd03mmc") XiaomiLYWSD03MMC = xiaomi_lywsd03mmc_ns.class_( - "XiaomiLYWSD03MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSD03MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsd03mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSD03MMC), @@ -54,15 +54,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index 7aa4809e24..356a4ffd4e 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd03mmc { static const char *const TAG = "xiaomi_lywsd03mmc"; @@ -21,7 +19,7 @@ void XiaomiLYWSD03MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSD03MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device void XiaomiLYWSD03MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_lywsd03mmc - -#endif diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index ecdbd412cb..a4f6e53215 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBT }; } // namespace esphome::xiaomi_lywsd03mmc - -#endif diff --git a/tests/components/xiaomi_lywsd02/common-ln.yaml b/tests/components/xiaomi_lywsd02/common-ln.yaml new file mode 100644 index 0000000000..ea3ec6647f --- /dev/null +++ b/tests/components/xiaomi_lywsd02/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_lywsd02 + mac_address: 3F:5B:7D:82:58:4E + temperature: + name: Xiaomi LYWSD02 Temperature + humidity: + name: Xiaomi LYWSD02 Humidity + battery_level: + name: Xiaomi LYWSD02 Battery Level diff --git a/tests/components/xiaomi_lywsd02/common.yaml b/tests/components/xiaomi_lywsd02/common.yaml index 3e40ab8d70..76638cec5e 100644 --- a/tests/components/xiaomi_lywsd02/common.yaml +++ b/tests/components/xiaomi_lywsd02/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd02 + ble_hub_id: ble_tracker_hub mac_address: 3F:5B:7D:82:58:4E temperature: name: Xiaomi LYWSD02 Temperature diff --git a/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml new file mode 100644 index 0000000000..cc3e0bca1e --- /dev/null +++ b/tests/components/xiaomi_lywsd02/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd02: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..59d9045e37 --- /dev/null +++ b/tests/components/xiaomi_lywsd02/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd02 + ble_hub_id: ble_tracker_hub + mac_address: 3F:5B:7D:82:58:4E + temperature: + name: Xiaomi LYWSD02 Temperature + humidity: + name: Xiaomi LYWSD02 Humidity + battery_level: + name: Xiaomi LYWSD02 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd02 + mac_address: 3F:5B:7D:82:58:4F + temperature: + name: BK Xiaomi LYWSD02 Implicit Temperature diff --git a/tests/components/xiaomi_lywsd02mmc/common-ln.yaml b/tests/components/xiaomi_lywsd02mmc/common-ln.yaml new file mode 100644 index 0000000000..9e81de78ae --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_lywsd02mmc + mac_address: A4:C1:38:54:5E:18 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: Xiaomi LYWSD02MMC Temperature + humidity: + name: Xiaomi LYWSD02MMC Humidity + battery_level: + name: Xiaomi LYWSD02MMC Battery Level diff --git a/tests/components/xiaomi_lywsd02mmc/common.yaml b/tests/components/xiaomi_lywsd02mmc/common.yaml index e63f585830..870a4f4916 100644 --- a/tests/components/xiaomi_lywsd02mmc/common.yaml +++ b/tests/components/xiaomi_lywsd02mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd02mmc + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:54:5E:18 bindkey: 2529d8e0d23150a588675cc54ad48400 temperature: diff --git a/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..bcbe4c20d5 --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd02mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f5266b65af --- /dev/null +++ b/tests/components/xiaomi_lywsd02mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd02mmc + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:54:5E:18 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: Xiaomi LYWSD02MMC Temperature + humidity: + name: Xiaomi LYWSD02MMC Humidity + battery_level: + name: Xiaomi LYWSD02MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd02mmc + mac_address: A4:C1:38:54:5E:19 + bindkey: 2529d8e0d23150a588675cc54ad48400 + temperature: + name: BK Xiaomi LYWSD02MMC Implicit Temperature diff --git a/tests/components/xiaomi_lywsd03mmc/common-ln.yaml b/tests/components/xiaomi_lywsd03mmc/common-ln.yaml new file mode 100644 index 0000000000..fe9b0b7b32 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_lywsd03mmc + mac_address: A4:C1:38:4E:16:78 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: Xiaomi LYWSD03MMC Temperature + humidity: + name: Xiaomi LYWSD03MMC Humidity + battery_level: + name: Xiaomi LYWSD03MMC Battery Level diff --git a/tests/components/xiaomi_lywsd03mmc/common.yaml b/tests/components/xiaomi_lywsd03mmc/common.yaml index d10a859c56..907fdb9078 100644 --- a/tests/components/xiaomi_lywsd03mmc/common.yaml +++ b/tests/components/xiaomi_lywsd03mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsd03mmc + ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: e9efaa6873f9f9c87a5e75a5f814801c temperature: diff --git a/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..c85742c495 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsd03mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..e13b4dac47 --- /dev/null +++ b/tests/components/xiaomi_lywsd03mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsd03mmc + ble_hub_id: ble_tracker_hub + mac_address: A4:C1:38:4E:16:78 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: Xiaomi LYWSD03MMC Temperature + humidity: + name: Xiaomi LYWSD03MMC Humidity + battery_level: + name: Xiaomi LYWSD03MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsd03mmc + mac_address: A4:C1:38:4E:16:79 + bindkey: e9efaa6873f9f9c87a5e75a5f814801c + temperature: + name: BK Xiaomi LYWSD03MMC Implicit Temperature From 98f4854ecde764feb376ad05cf63924491477971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 09:01:24 +0300 Subject: [PATCH 030/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 11: xiaomi_lywsdcgq, xiaomi_mhoc303, xiaomi_mhoc401) (#18178) --- esphome/components/xiaomi_lywsdcgq/sensor.py | 14 +++++------ .../xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp | 6 +---- .../xiaomi_lywsdcgq/xiaomi_lywsdcgq.h | 10 +++----- esphome/components/xiaomi_mhoc303/sensor.py | 14 +++++------ .../xiaomi_mhoc303/xiaomi_mhoc303.cpp | 6 +---- .../xiaomi_mhoc303/xiaomi_mhoc303.h | 10 +++----- esphome/components/xiaomi_mhoc401/sensor.py | 14 +++++------ .../xiaomi_mhoc401/xiaomi_mhoc401.cpp | 6 +---- .../xiaomi_mhoc401/xiaomi_mhoc401.h | 10 +++----- .../components/xiaomi_lywsdcgq/common-ln.yaml | 9 +++++++ tests/components/xiaomi_lywsdcgq/common.yaml | 3 +++ .../xiaomi_lywsdcgq/test.ln882x-ard.yaml | 3 +++ .../xiaomi_lywsdcgq/validate.bk72xx-ard.yaml | 22 +++++++++++++++++ .../components/xiaomi_mhoc303/common-ln.yaml | 9 +++++++ tests/components/xiaomi_mhoc303/common.yaml | 3 +++ .../xiaomi_mhoc303/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mhoc303/validate.bk72xx-ard.yaml | 22 +++++++++++++++++ .../components/xiaomi_mhoc401/common-ln.yaml | 10 ++++++++ tests/components/xiaomi_mhoc401/common.yaml | 9 ++++--- .../xiaomi_mhoc401/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mhoc401/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ 21 files changed, 150 insertions(+), 60 deletions(-) create mode 100644 tests/components/xiaomi_lywsdcgq/common-ln.yaml create mode 100644 tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mhoc303/common-ln.yaml create mode 100644 tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mhoc401/common-ln.yaml create mode 100644 tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 5d964ea22a..0fbe4fcda9 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_lywsdcgq_ns = cg.esphome_ns.namespace("xiaomi_lywsdcgq") XiaomiLYWSDCGQ = xiaomi_lywsdcgq_ns.class_( - "XiaomiLYWSDCGQ", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiLYWSDCGQ", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_lywsdcgq"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiLYWSDCGQ), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 56efaaef51..1ddf7ec235 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -1,8 +1,6 @@ #include "xiaomi_lywsdcgq.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsdcgq { static const char *const TAG = "xiaomi_lywsdcgq"; @@ -14,7 +12,7 @@ void XiaomiLYWSDCGQ::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiLYWSDCGQ::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiLYWSDCGQ::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_lywsdcgq - -#endif diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index 86afef4571..5cecc2f78a 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDe }; } // namespace esphome::xiaomi_lywsdcgq - -#endif diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 86c4d6699f..de1b3ea4b8 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,15 +16,15 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mhoc303_ns = cg.esphome_ns.namespace("xiaomi_mhoc303") XiaomiMHOC303 = xiaomi_mhoc303_ns.class_( - "XiaomiMHOC303", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMHOC303", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mhoc303"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMHOC303), @@ -50,15 +50,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 74626ed0a5..9706e50861 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -1,8 +1,6 @@ #include "xiaomi_mhoc303.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc303 { static const char *const TAG = "xiaomi_mhoc303"; @@ -14,7 +12,7 @@ void XiaomiMHOC303::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMHOC303::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -55,5 +53,3 @@ bool XiaomiMHOC303::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_mhoc303 - -#endif diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index 042a5034f1..a15b58f8ed 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -28,5 +26,3 @@ class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_mhoc303 - -#endif diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index 7161e88da5..4604af218e 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -18,15 +18,15 @@ from esphome.const import ( ) CODEOWNERS = ["@vevsvevs"] -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mhoc401_ns = cg.esphome_ns.namespace("xiaomi_mhoc401") XiaomiMHOC401 = xiaomi_mhoc401_ns.class_( - "XiaomiMHOC401", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMHOC401", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mhoc401"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMHOC401), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 958ac59bde..d725978418 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc401 { static const char *const TAG = "xiaomi_mhoc401"; @@ -21,7 +19,7 @@ void XiaomiMHOC401::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMHOC401::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { void XiaomiMHOC401::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_mhoc401 - -#endif diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 3570f70a16..3978e557f0 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -29,5 +27,3 @@ class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_mhoc401 - -#endif diff --git a/tests/components/xiaomi_lywsdcgq/common-ln.yaml b/tests/components/xiaomi_lywsdcgq/common-ln.yaml new file mode 100644 index 0000000000..6a458a5b2a --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_lywsdcgq + mac_address: 7A:80:8E:19:36:BA + temperature: + name: Xiaomi LYWSDCGQ Temperature + humidity: + name: Xiaomi LYWSDCGQ Humidity + battery_level: + name: Xiaomi LYWSDCGQ Battery Level diff --git a/tests/components/xiaomi_lywsdcgq/common.yaml b/tests/components/xiaomi_lywsdcgq/common.yaml index d8422b4c0c..147b77c2d1 100644 --- a/tests/components/xiaomi_lywsdcgq/common.yaml +++ b/tests/components/xiaomi_lywsdcgq/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_lywsdcgq + ble_hub_id: ble_tracker_hub mac_address: 7A:80:8E:19:36:BA temperature: name: Xiaomi LYWSDCGQ Temperature diff --git a/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml b/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml new file mode 100644 index 0000000000..48aa38be38 --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_lywsdcgq: !include common-ln.yaml diff --git a/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml b/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..030f74afa3 --- /dev/null +++ b/tests/components/xiaomi_lywsdcgq/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_lywsdcgq + ble_hub_id: ble_tracker_hub + mac_address: 7A:80:8E:19:36:BA + temperature: + name: Xiaomi LYWSDCGQ Temperature + humidity: + name: Xiaomi LYWSDCGQ Humidity + battery_level: + name: Xiaomi LYWSDCGQ Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_lywsdcgq + mac_address: 7A:80:8E:19:36:BB + temperature: + name: BK Xiaomi LYWSDCGQ Implicit Temperature diff --git a/tests/components/xiaomi_mhoc303/common-ln.yaml b/tests/components/xiaomi_mhoc303/common-ln.yaml new file mode 100644 index 0000000000..ca89047a68 --- /dev/null +++ b/tests/components/xiaomi_mhoc303/common-ln.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: xiaomi_mhoc303 + mac_address: E7:50:59:32:A0:1C + temperature: + name: MHO-C303 Temperature + humidity: + name: MHO-C303 Humidity + battery_level: + name: MHO-C303 Battery Level diff --git a/tests/components/xiaomi_mhoc303/common.yaml b/tests/components/xiaomi_mhoc303/common.yaml index e4353d3c6a..74c96fc26d 100644 --- a/tests/components/xiaomi_mhoc303/common.yaml +++ b/tests/components/xiaomi_mhoc303/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mhoc303 + ble_hub_id: ble_tracker_hub mac_address: E7:50:59:32:A0:1C temperature: name: MHO-C303 Temperature diff --git a/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml b/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6e927dafe8 --- /dev/null +++ b/tests/components/xiaomi_mhoc303/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mhoc303: !include common-ln.yaml diff --git a/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..5c7f29c98e --- /dev/null +++ b/tests/components/xiaomi_mhoc303/validate.bk72xx-ard.yaml @@ -0,0 +1,22 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mhoc303 + ble_hub_id: ble_tracker_hub + mac_address: E7:50:59:32:A0:1C + temperature: + name: MHO-C303 Temperature + humidity: + name: MHO-C303 Humidity + battery_level: + name: MHO-C303 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mhoc303 + mac_address: E7:50:59:32:A0:1D + temperature: + name: BK MHO-C303 Implicit Temperature diff --git a/tests/components/xiaomi_mhoc401/common-ln.yaml b/tests/components/xiaomi_mhoc401/common-ln.yaml new file mode 100644 index 0000000000..43641f66d1 --- /dev/null +++ b/tests/components/xiaomi_mhoc401/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_mhoc401 + mac_address: E7:50:59:32:A0:1C + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: MHO-C401 Temperature + humidity: + name: MHO-C401 Humidity + battery_level: + name: MHO-C401 Battery Level diff --git a/tests/components/xiaomi_mhoc401/common.yaml b/tests/components/xiaomi_mhoc401/common.yaml index ae378f5604..646961b3b6 100644 --- a/tests/components/xiaomi_mhoc401/common.yaml +++ b/tests/components/xiaomi_mhoc401/common.yaml @@ -1,12 +1,15 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mhoc401 + ble_hub_id: ble_tracker_hub mac_address: E7:50:59:32:A0:1C bindkey: "eef418daf699a0c188f3bfd17e4565d9" temperature: - name: MHO-C303 Temperature + name: MHO-C401 Temperature humidity: - name: MHO-C303 Humidity + name: MHO-C401 Humidity battery_level: - name: MHO-C303 Battery Level + name: MHO-C401 Battery Level diff --git a/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml b/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml new file mode 100644 index 0000000000..a20f24671d --- /dev/null +++ b/tests/components/xiaomi_mhoc401/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mhoc401: !include common-ln.yaml diff --git a/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..f7a2bd3e4f --- /dev/null +++ b/tests/components/xiaomi_mhoc401/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mhoc401 + ble_hub_id: ble_tracker_hub + mac_address: E7:50:59:32:A0:1C + bindkey: "eef418daf699a0c188f3bfd17e4565d9" + temperature: + name: MHO-C401 Temperature + humidity: + name: MHO-C401 Humidity + battery_level: + name: MHO-C401 Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mhoc401 + mac_address: E7:50:59:32:A0:1D + bindkey: eef418daf699a0c188f3bfd17e4565d9 + temperature: + name: BK MHO-C401 Implicit Temperature From 252bb3333eecfd6fad95464437e86d625d91a6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 09:34:08 +0300 Subject: [PATCH 031/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 12: xiaomi_miscale, xiaomi_mjyd02yla, xiaomi_mue4094rt) (#18180) --- esphome/components/xiaomi_miscale/sensor.py | 13 +++++----- .../xiaomi_miscale/xiaomi_miscale.cpp | 16 +++++-------- .../xiaomi_miscale/xiaomi_miscale.h | 12 ++++------ .../xiaomi_mjyd02yla/binary_sensor.py | 12 +++++----- .../xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp | 6 +---- .../xiaomi_mjyd02yla/xiaomi_mjyd02yla.h | 10 +++----- .../xiaomi_mue4094rt/binary_sensor.py | 12 +++++----- .../xiaomi_mue4094rt/xiaomi_mue4094rt.cpp | 6 +---- .../xiaomi_mue4094rt/xiaomi_mue4094rt.h | 10 +++----- .../components/xiaomi_miscale/common-ln.yaml | 7 ++++++ tests/components/xiaomi_miscale/common.yaml | 3 +++ .../xiaomi_miscale/test.ln882x-ard.yaml | 3 +++ .../xiaomi_miscale/validate.bk72xx-ard.yaml | 20 ++++++++++++++++ .../xiaomi_mjyd02yla/common-ln.yaml | 11 +++++++++ tests/components/xiaomi_mjyd02yla/common.yaml | 3 +++ .../xiaomi_mjyd02yla/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mjyd02yla/validate.bk72xx-ard.yaml | 24 +++++++++++++++++++ .../xiaomi_mue4094rt/common-ln.yaml | 5 ++++ tests/components/xiaomi_mue4094rt/common.yaml | 3 +++ .../xiaomi_mue4094rt/test.ln882x-ard.yaml | 3 +++ .../xiaomi_mue4094rt/validate.bk72xx-ard.yaml | 18 ++++++++++++++ 21 files changed, 140 insertions(+), 60 deletions(-) create mode 100644 tests/components/xiaomi_miscale/common-ln.yaml create mode 100644 tests/components/xiaomi_miscale/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mjyd02yla/common-ln.yaml create mode 100644 tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_mue4094rt/common-ln.yaml create mode 100644 tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index 14e5c1d376..8a2ac6bbb3 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR_IMPEDANCE, @@ -15,14 +15,15 @@ from esphome.const import ( UNIT_OHM, ) -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] xiaomi_miscale_ns = cg.esphome_ns.namespace("xiaomi_miscale") XiaomiMiscale = xiaomi_miscale_ns.class_( - "XiaomiMiscale", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiMiscale", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_miscale"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiMiscale), @@ -43,15 +44,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 2b1492129c..482c0ed395 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -1,9 +1,7 @@ #include "xiaomi_miscale.h" -#include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_miscale { static const char *const TAG = "xiaomi_miscale"; @@ -14,7 +12,7 @@ void XiaomiMiscale::dump_config() { LOG_SENSOR(" ", "Impedance", this->impedance_); } -bool XiaomiMiscale::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMiscale::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -56,14 +54,14 @@ bool XiaomiMiscale::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -optional XiaomiMiscale::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional XiaomiMiscale::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; - if (service_data.uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(0x181D) && service_data.data.size() == 10) { + if (service_data.uuid == ble_device_base::ESPBTUUID::from_uint16(0x181D) && service_data.data.size() == 10) { result.version = 1; - } else if (service_data.uuid == esp32_ble_tracker::ESPBTUUID::from_uint16(0x181B) && service_data.data.size() == 13) { + } else if (service_data.uuid == ble_device_base::ESPBTUUID::from_uint16(0x181B) && service_data.data.size() == 13) { result.version = 2; } else { - char uuid_buf[esp32_ble::UUID_STR_LEN]; + char uuid_buf[ble_device_base::UUID_STR_LEN]; ESP_LOGVV(TAG, "parse_header(): Couldn't identify scale version or data size was not correct. UUID: %s, data_size: %d", service_data.uuid.to_str(uuid_buf), service_data.data.size()); @@ -167,5 +165,3 @@ bool XiaomiMiscale::report_results_(const optional &result, const c } } // namespace esphome::xiaomi_miscale - -#endif diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index 3213f5d6de..64cc2ff567 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::xiaomi_miscale { struct ParseResult { @@ -16,11 +14,11 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_weight(sensor::Sensor *weight) { weight_ = weight; } void set_impedance(sensor::Sensor *impedance) { impedance_ = impedance; } @@ -32,7 +30,7 @@ class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDev sensor::Sensor *impedance_{nullptr}; bool clear_impedance_{false}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool parse_message_v1_(const std::vector &message, ParseResult &result); bool parse_message_v2_(const std::vector &message, ParseResult &result); @@ -40,5 +38,3 @@ class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDev }; } // namespace esphome::xiaomi_miscale - -#endif diff --git a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py index 312abc82cb..4cfc82d2c6 100644 --- a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py +++ b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -20,18 +20,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_mjyd02yla_ns = cg.esphome_ns.namespace("xiaomi_mjyd02yla") XiaomiMJYD02YLA = xiaomi_mjyd02yla_ns.class_( "XiaomiMJYD02YLA", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mjyd02yla"), binary_sensor.binary_sensor_schema( XiaomiMJYD02YLA, device_class=DEVICE_CLASS_MOTION ) @@ -63,15 +63,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index a7b2554aad..233f5f5783 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mjyd02yla { static const char *const TAG = "xiaomi_mjyd02yla"; @@ -17,7 +15,7 @@ void XiaomiMJYD02YLA::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool XiaomiMJYD02YLA::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMJYD02YLA::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -65,5 +63,3 @@ bool XiaomiMJYD02YLA::parse_device(const esp32_ble_tracker::ESPBTDevice &device) void XiaomiMJYD02YLA::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_mjyd02yla - -#endif diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index da02dee003..ba2fe1b62c 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -3,21 +3,19 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mjyd02yla { class XiaomiMJYD02YLA final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_idle_time(sensor::Sensor *idle_time) { idle_time_ = idle_time; } @@ -35,5 +33,3 @@ class XiaomiMJYD02YLA final : public Component, }; } // namespace esphome::xiaomi_mjyd02yla - -#endif diff --git a/esphome/components/xiaomi_mue4094rt/binary_sensor.py b/esphome/components/xiaomi_mue4094rt/binary_sensor.py index c5d93384c9..6df8dcb8ea 100644 --- a/esphome/components/xiaomi_mue4094rt/binary_sensor.py +++ b/esphome/components/xiaomi_mue4094rt/binary_sensor.py @@ -1,21 +1,21 @@ from esphome import core import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import CONF_MAC_ADDRESS, CONF_TIMEOUT, DEVICE_CLASS_MOTION -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] xiaomi_mue4094rt_ns = cg.esphome_ns.namespace("xiaomi_mue4094rt") XiaomiMUE4094RT = xiaomi_mue4094rt_ns.class_( "XiaomiMUE4094RT", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mue4094rt"), binary_sensor.binary_sensor_schema( XiaomiMUE4094RT, device_class=DEVICE_CLASS_MOTION ) @@ -28,15 +28,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_time(config[CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index 259e0159c5..eca83c0912 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -1,8 +1,6 @@ #include "xiaomi_mue4094rt.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mue4094rt { static const char *const TAG = "xiaomi_mue4094rt"; @@ -12,7 +10,7 @@ void XiaomiMUE4094RT::dump_config() { LOG_BINARY_SENSOR(" ", "Motion", this); } -bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiMUE4094RT::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -51,5 +49,3 @@ bool XiaomiMUE4094RT::parse_device(const esp32_ble_tracker::ESPBTDevice &device) } } // namespace esphome::xiaomi_mue4094rt - -#endif diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index 4751e35e65..1ca40bf8ca 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -2,20 +2,18 @@ #include "esphome/core/component.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_mue4094rt { class XiaomiMUE4094RT final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_time(uint16_t timeout) { timeout_ = timeout; } @@ -26,5 +24,3 @@ class XiaomiMUE4094RT final : public Component, }; } // namespace esphome::xiaomi_mue4094rt - -#endif diff --git a/tests/components/xiaomi_miscale/common-ln.yaml b/tests/components/xiaomi_miscale/common-ln.yaml new file mode 100644 index 0000000000..38c3287402 --- /dev/null +++ b/tests/components/xiaomi_miscale/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: xiaomi_miscale + mac_address: '5C:CA:D3:70:D4:A2' + weight: + name: "Xiaomi Mi Scale Weight" + impedance: + name: "Xiaomi Mi Scale Impedance" diff --git a/tests/components/xiaomi_miscale/common.yaml b/tests/components/xiaomi_miscale/common.yaml index 89f32ad199..673db86311 100644 --- a/tests/components/xiaomi_miscale/common.yaml +++ b/tests/components/xiaomi_miscale/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_miscale + ble_hub_id: ble_tracker_hub mac_address: '5C:CA:D3:70:D4:A2' weight: name: "Xiaomi Mi Scale Weight" diff --git a/tests/components/xiaomi_miscale/test.ln882x-ard.yaml b/tests/components/xiaomi_miscale/test.ln882x-ard.yaml new file mode 100644 index 0000000000..88c5054ae3 --- /dev/null +++ b/tests/components/xiaomi_miscale/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_miscale: !include common-ln.yaml diff --git a/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml b/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..46fc7d0a2a --- /dev/null +++ b/tests/components/xiaomi_miscale/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_miscale + ble_hub_id: ble_tracker_hub + mac_address: '5C:CA:D3:70:D4:A2' + weight: + name: "Xiaomi Mi Scale Weight" + impedance: + name: "Xiaomi Mi Scale Impedance" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_miscale + mac_address: '5C:CA:D3:70:D4:A3' + weight: + name: "BK Xiaomi Mi Scale Implicit Weight" diff --git a/tests/components/xiaomi_mjyd02yla/common-ln.yaml b/tests/components/xiaomi_mjyd02yla/common-ln.yaml new file mode 100644 index 0000000000..04117e1565 --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/common-ln.yaml @@ -0,0 +1,11 @@ +binary_sensor: + - platform: xiaomi_mjyd02yla + name: MJYD02YL-A Motion + mac_address: 50:EC:50:CD:32:02 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + idle_time: + name: MJYD02YL-A Idle Time + light: + name: MJYD02YL-A Light Status + battery_level: + name: MJYD02YL-A Battery Level diff --git a/tests/components/xiaomi_mjyd02yla/common.yaml b/tests/components/xiaomi_mjyd02yla/common.yaml index dffcef84c4..1a2c67c971 100644 --- a/tests/components/xiaomi_mjyd02yla/common.yaml +++ b/tests/components/xiaomi_mjyd02yla/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mjyd02yla + ble_hub_id: ble_tracker_hub name: MJYD02YL-A Motion mac_address: 50:EC:50:CD:32:02 bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml b/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml new file mode 100644 index 0000000000..3e7ec5e9ba --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mjyd02yla: !include common-ln.yaml diff --git a/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..b47069a939 --- /dev/null +++ b/tests/components/xiaomi_mjyd02yla/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mjyd02yla + ble_hub_id: ble_tracker_hub + name: MJYD02YL-A Motion + mac_address: 50:EC:50:CD:32:02 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 + idle_time: + name: MJYD02YL-A Idle Time + light: + name: MJYD02YL-A Light Status + battery_level: + name: MJYD02YL-A Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mjyd02yla + name: BK MJYD02YL-A Implicit Motion + mac_address: 50:EC:50:CD:32:03 + bindkey: 48403ebe2d385db8d0c187f81e62cb64 diff --git a/tests/components/xiaomi_mue4094rt/common-ln.yaml b/tests/components/xiaomi_mue4094rt/common-ln.yaml new file mode 100644 index 0000000000..9d28a7e7f8 --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/common-ln.yaml @@ -0,0 +1,5 @@ +binary_sensor: + - platform: xiaomi_mue4094rt + name: MUE4094RT Motion + mac_address: 7A:80:8E:19:36:BA + timeout: 5s diff --git a/tests/components/xiaomi_mue4094rt/common.yaml b/tests/components/xiaomi_mue4094rt/common.yaml index 4f0e5ccbae..bd5d9348ea 100644 --- a/tests/components/xiaomi_mue4094rt/common.yaml +++ b/tests/components/xiaomi_mue4094rt/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_mue4094rt + ble_hub_id: ble_tracker_hub name: MUE4094RT Motion mac_address: 7A:80:8E:19:36:BA timeout: 5s diff --git a/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml b/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml new file mode 100644 index 0000000000..bd2ccc4e59 --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_mue4094rt: !include common-ln.yaml diff --git a/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml b/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..10a537089b --- /dev/null +++ b/tests/components/xiaomi_mue4094rt/validate.bk72xx-ard.yaml @@ -0,0 +1,18 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_mue4094rt + ble_hub_id: ble_tracker_hub + name: MUE4094RT Motion + mac_address: 7A:80:8E:19:36:BA + timeout: 5s + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_mue4094rt + name: BK MUE4094RT Implicit Motion + mac_address: 7A:80:8E:19:36:BB + timeout: 5s From 2730c10c2c365057fa0136df3f9e1ca1947d6a25 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Aug 2026 08:35:49 +0200 Subject: [PATCH 032/597] [modbus] Route broadcast writes (address 0) to all server devices (#17387) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/modbus/__init__.py | 19 +- esphome/components/modbus/modbus.cpp | 167 +++++++---- esphome/components/modbus/modbus.h | 29 +- .../components/modbus/modbus_definitions.h | 4 + .../modbus_server/modbus_server.cpp | 7 +- tests/component_tests/modbus/test_modbus.py | 39 +++ .../modbus/modbus_broadcast_test.cpp | 276 ++++++++++++++++++ 7 files changed, 483 insertions(+), 58 deletions(-) create mode 100644 tests/component_tests/modbus/test_modbus.py create mode 100644 tests/components/modbus/modbus_broadcast_test.cpp diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index c91032801b..377dadad76 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Literal +from typing import Any, Literal from esphome import pins import esphome.codegen as cg @@ -99,15 +99,28 @@ async def to_code(config): cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) +def _validate_server_address(value: Any) -> int: + address = cv.hex_uint8_t(value) + # The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1), + # so it cannot identify an individual server device. + if address == 0: + raise cv.Invalid( + "Address 0 is the Modbus broadcast address and cannot be used as a " + "server device address. Assign a unique unit address instead." + ) + return address + + def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): hub_type = ModbusClient if role == "client" else ModbusServer + address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t schema = { cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: - schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t + schema[cv.Required(CONF_ADDRESS)] = address_validator else: - schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t + schema[cv.Optional(CONF_ADDRESS, default=default_address)] = address_validator return cv.Schema(schema) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index db97d56cc6..87aace02d0 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -183,6 +183,10 @@ void ModbusServerHub::parse_modbus_frames() { size_t size = this->rx_buffer_.size(); ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); bool retry_as_client = false; + // A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex). + const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS; + if (is_broadcast) + this->expecting_peer_response_ = 0; if (this->expecting_peer_response_ != 0) { if (!this->parse_modbus_server_frame_()) { ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", @@ -277,11 +281,17 @@ bool ModbusServerHub::parse_modbus_client_frame_() { // This requires copying the frame data to a local buffer beforehand. uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); uint16_t data_len = frame_length - 2 - data_offset; - uint8_t data[MAX_FRAME_SIZE] = {}; - std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + uint8_t data_buffer[MAX_FRAME_SIZE] = {}; + std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len); + std::span data(data_buffer, data_len); this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); - this->process_modbus_client_frame_(address, function_code, data); + if (address == BROADCAST_ADDRESS) { + // Keep the unicast response buffers out of the broadcast call chain. + this->process_broadcast_frame_(function_code, data); + } else { + this->process_modbus_client_frame_(address, function_code, data); + } return true; } @@ -365,15 +375,85 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { return nullptr; } -bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { +ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) { if ((uint32_t) start_address + number_of_registers > 0x10000u) { ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, number_of_registers); - this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS); - return false; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + return std::nullopt; +} + +// Write PDU layout after the function code: start address(2) [+ quantity(2) + byte count(1)] + register values. +// The value subspans taken at these offsets stay in range because client_pdu_length() clamps the byte count to the +// same maximum the callers' number_of_registers * 2 == number_of_bytes guard enforces. +static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2; +static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5; +// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1). +static constexpr size_t READ_WRITE_VALUES_OFFSET = 9; + +ResponseStatus ModbusServerHub::parse_write_single_(std::span data, uint16_t &start_address, + RegisterValues ®isters) { + start_address = helpers::get_data(data.data(), 0); + // No range check needed: one register can never push start_address + 1 past the address space. + this->assemble_registers_(data.subspan(WRITE_SINGLE_VALUES_OFFSET, sizeof(uint16_t)), registers); + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_write_multiple_(std::span data, uint16_t &start_address, + RegisterValues ®isters) { + start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); + uint8_t number_of_bytes = helpers::get_data(data.data(), 4); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) { + return status; + } + this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers); + return std::nullopt; +} + +void ModbusServerHub::assemble_registers_(std::span values, RegisterValues ®isters) { + for (size_t offset = 0; offset + 1 < values.size(); offset += 2) { + registers.push_back(helpers::get_data(values.data(), offset)); + } +} + +void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span data) { + // Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an + // unsupported function code or a validation failure is silently dropped instead of replying with an exception. + // Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet. + uint16_t start_address; + RegisterValues registers; + ResponseStatus status; + switch (static_cast(function_code)) { + case FunctionCode::WRITE_SINGLE_REGISTER: + status = this->parse_write_single_(data, start_address, registers); + break; + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + status = this->parse_write_multiple_(data, start_address, registers); + break; + default: + // Reads and read/write require a reply, so they are not valid as broadcasts. + ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code); + return; + } + if (status.has_value()) { + return; + } + for (auto *device : this->devices_) { + // A broadcast is never answered, so a rejecting device has no other feedback channel; log it so a + // misconfigured register map is diagnosable instead of looking identical to a successful write. + if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers); + device_status.has_value()) { + ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), + static_cast(device_status.value())); + } } - return true; } bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status, @@ -420,7 +500,8 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu return true; } -void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, + std::span data) { ModbusServerDevice *device = this->find_device_(address); if (device == nullptr) { this->expecting_peer_response_ = address; @@ -437,14 +518,16 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: { // PDU data: start address(2) + quantity(2). - uint16_t start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = helpers::get_data(data, 2); + uint16_t start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } - if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + status = this->check_register_range_(start_address, number_of_registers); + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); return; } RegisterValues registers; @@ -462,46 +545,31 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_MULTIPLE_REGISTERS: { - // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. - // A single-register write always targets one register; for a multiple-register write the - // quantity is in the frame and its byte count must equal quantity * 2. The register values are - // assembled into registers below so the handler doesn't have to know the request framing. - uint16_t start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = 1; - uint16_t values_offset = 2; // single write: values follow the 2-byte start address - if (static_cast(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) { - number_of_registers = helpers::get_data(data, 2); - uint8_t number_of_bytes = helpers::get_data(data, 4); - values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) - if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || - number_of_registers * 2 != number_of_bytes) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, - number_of_bytes); - this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { - return; - } - } - // Assemble the register values (host byte order) so the handler never sees wire framing. + // Parse and validate the write PDU into host-order register values; reply with an exception on failure. + uint16_t start_address; RegisterValues registers; - for (uint16_t i = 0; i < number_of_registers; i++) { - registers.push_back(helpers::get_data(data, values_offset + i * 2)); + if (static_cast(function_code) == FunctionCode::WRITE_SINGLE_REGISTER) { + status = this->parse_write_single_(data, start_address, registers); + } else { + status = this->parse_write_multiple_(data, start_address, registers); + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; } status = device->on_write_registers(start_address, registers); - response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_data = data.data(); // echo the request header per Modbus 6.6, 6.12 response_len = 4; break; } case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. - uint16_t read_start_address = helpers::get_data(data, 0); - uint16_t number_of_registers = helpers::get_data(data, 2); - uint16_t write_start_address = helpers::get_data(data, 4); - uint16_t number_of_write_registers = helpers::get_data(data, 6); - uint8_t number_of_bytes = helpers::get_data(data, 8); + uint16_t read_start_address = helpers::get_data(data.data(), 0); + uint16_t number_of_registers = helpers::get_data(data.data(), 2); + uint16_t write_start_address = helpers::get_data(data.data(), 4); + uint16_t number_of_write_registers = helpers::get_data(data.data(), 6); + uint8_t number_of_bytes = helpers::get_data(data.data(), 8); if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ || number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW || number_of_write_registers * 2 != number_of_bytes) { @@ -510,18 +578,19 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } - if (!this->check_register_range_(address, function_code, read_start_address, number_of_registers) || - !this->check_register_range_(address, function_code, write_start_address, number_of_write_registers)) { + status = this->check_register_range_(read_start_address, number_of_registers); + if (!status.has_value()) { + status = this->check_register_range_(write_start_address, number_of_write_registers); + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); return; } // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read // values are allocated, keeping only one RegisterValues buffer live at a time. { - // Assemble the written register values (host byte order); they follow the 9-byte request header. RegisterValues write_registers; - for (uint16_t i = 0; i < number_of_write_registers; i++) { - write_registers.push_back(helpers::get_data(data, 9 + i * 2)); - } + this->assemble_registers_(data.subspan(READ_WRITE_VALUES_OFFSET, number_of_bytes), write_registers); // Dispatch to the standalone write and read handlers so any device implementing those supports 0x17 // without a dedicated handler; a device that maps registers by address reconstructs the read response // from the values it just stored. diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 9f88213985..5a700912de 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -330,12 +330,22 @@ class ModbusServerHub : public Modbus { void parse_modbus_frames() override; bool parse_modbus_client_frame_(); void process_modbus_server_frame(uint8_t address, std::span pdu) override; - void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data); + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span data); + // Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered. + void process_broadcast_frame_(uint8_t function_code, std::span data); + // Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register + // values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus + // exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast + // writes (which silently drop invalid frames). + ResponseStatus parse_write_single_(std::span data, uint16_t &start_address, RegisterValues ®isters); + ResponseStatus parse_write_multiple_(std::span data, uint16_t &start_address, + RegisterValues ®isters); + // Appends the big-endian register values in values to registers, in host byte order. + void assemble_registers_(std::span values, RegisterValues ®isters); ModbusServerDevice *find_device_(uint8_t address); - // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. - // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. - bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers); + // Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space, + // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. + ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers); // Builds the body of a register read response (byte count followed by the big-endian register values) into // response_buffer. Shared by every function code that answers with register values, so the read reply stays @@ -603,9 +613,18 @@ class ModbusServerDevice { virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ExceptionCode::ILLEGAL_FUNCTION; }; + // Hub entry point for broadcast (address 0) writes, which are never answered. + ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues ®isters) { + this->broadcast_write_ = true; + ResponseStatus status = this->on_write_registers(start_address, registers); + this->broadcast_write_ = false; + return status; + } protected: uint8_t address_{0}; + // Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence. + bool broadcast_write_{false}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index b55b3ebe01..9ec776b67a 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -116,6 +116,10 @@ static constexpr uint16_t READ_PDU_SIZE = 5; // A single-write PDU is always function code(1) + address(2) + value(2) static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5; static constexpr uint16_t MAX_FRAME_SIZE = 256; + +// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered. +static constexpr uint8_t BROADCAST_ADDRESS = 0; + // Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client // PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never // has to check the framed size - it cannot be exceeded. diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e649635848..bf39efbd54 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -145,7 +145,12 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, } return true; })) { - ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + // On a broadcast every device that does not map these registers rejects them, which is the normal case. + if (this->broadcast_write_) { + ESP_LOGV(TAG, "Write request rejected before applying any register."); + } else { + ESP_LOGW(TAG, "Write request rejected before applying any register."); + } return precheck; } diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py new file mode 100644 index 0000000000..0e53c55b50 --- /dev/null +++ b/tests/component_tests/modbus/test_modbus.py @@ -0,0 +1,39 @@ +"""Tests for modbus configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address +from esphome.const import CONF_ADDRESS + + +def test_server_address_accepts_valid_unit_address() -> None: + # A normal unit address (1-247) is accepted and returned as an int. + assert _validate_server_address(1) == 1 + assert _validate_server_address(247) == 247 + + +def test_server_address_accepts_hex_string() -> None: + # hex_uint8_t parses hex strings, and the validator returns the parsed int. + assert _validate_server_address("0x10") == 0x10 + + +def test_server_address_zero_rejected() -> None: + # Address 0 is the Modbus broadcast address and cannot identify a server device. + with pytest.raises(cv.Invalid, match="broadcast address"): + _validate_server_address(0) + + +def test_server_schema_rejects_address_zero() -> None: + # The server-role schema wires in _validate_server_address, so address 0 is rejected there too. + schema = modbus.modbus_device_schema(0x01, role="server") + with pytest.raises(cv.Invalid, match="broadcast address"): + schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0}) + + +def test_client_schema_still_accepts_address_zero() -> None: + # Not rejected for clients today, but not supported either: a client broadcast gets no reply and + # stalls the hub for the full send-wait. + schema = modbus.modbus_device_schema(0x01) + assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/components/modbus/modbus_broadcast_test.cpp b/tests/components/modbus/modbus_broadcast_test.cpp new file mode 100644 index 0000000000..5840259021 --- /dev/null +++ b/tests/components/modbus/modbus_broadcast_test.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus { + +namespace { + +// A server device that records the writes the hub routes to it. +class RecordingDevice : public ModbusServerDevice { + public: + explicit RecordingDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override { + this->write_count++; + this->last_start_address = start_address; + this->last_values.assign(registers.begin(), registers.end()); + return std::nullopt; // return value is ignored for broadcasts, which are never answered + } + + int write_count{0}; + uint16_t last_start_address{0}; + std::vector last_values; +}; + +// A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch. +class RejectingDevice : public ModbusServerDevice { + public: + explicit RejectingDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) override { + this->write_count++; + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + int write_count{0}; +}; + +// A UART that records every byte written so the test can assert the hub sends no reply. +class RecordingUART : public testing::NullUART { + public: + void write_array(const uint8_t *data, size_t len) override { + this->written.insert(this->written.end(), data, data + len); + } + std::vector written; +}; + +// Drives full frames through the server hub's receive path in tests. +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + pdu + CRC) and runs the full receive-side parser + // (parse_modbus_frames), so the expecting-peer-response routing is exercised, not just the frame parser + // below it. Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data, + size_t pdu_data_len) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(pdu_data_len + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// A broadcast (address 0) single-register write reaches every registered device and is not answered. +// Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length, +// CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it. +TEST(ModbusBroadcast, SingleRegisterWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC). + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 1u); + EXPECT_EQ(device->last_values[0], 0x00A5); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// A single-register broadcast (FC 0x06) must still reach every device when the hub is mid-way through +// waiting for a peer's response. Its frame length matches a response frame, so without the address-0 guard +// in parse_modbus_frames() it would be swallowed by the response parser instead of being dispatched. +TEST(ModbusBroadcast, SingleRegisterBroadcastDispatchedWhileExpectingPeerResponse) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // A unicast write addressed to an unregistered peer (0x09) leaves the hub expecting that peer's response. + const uint8_t peer_pdu[] = {0x00, 0x10, 0x00, 0x2A}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), peer_pdu, + sizeof(peer_pdu))); + ASSERT_EQ(device_a.write_count, 0); // the peer request is not for our devices + ASSERT_EQ(device_b.write_count, 0); + + // The broadcast that follows must still be delivered to every device, and still without a reply. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 1u); + EXPECT_EQ(device->last_values[0], 0x00A5); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// After dispatching a broadcast, the hub must not still expect a peer response: a following unicast FC 0x06 +// to one of our own devices must be handled, not misparsed as that peer's response and dropped. +TEST(ModbusBroadcast, BroadcastClearsStalePeerExpectation) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // A unicast write to an unregistered peer (0x09) leaves the hub expecting that peer's response. + const uint8_t pdu_data[] = {0x00, 0x10, 0x00, 0x2A}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x09, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, + sizeof(pdu_data))); + + // The broadcast that follows clears that expectation as it is dispatched. + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + ASSERT_EQ(device.write_count, 1); + + // The next unicast FC 0x06 to our own device is handled, not swallowed by the stale expectation. + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.write_count, 2); +} + +// A broadcast multi-register write is decoded and delivered to every device, still without a reply. +TEST(ModbusBroadcast, MultipleRegisterWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x10 payload: start 0x9D31, quantity 2, byte count 4, values 0x0102 and 0x0304. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data))); + + for (RecordingDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x9D31); + ASSERT_EQ(device->last_values.size(), 2u); + EXPECT_EQ(device->last_values[0], 0x0102); + EXPECT_EQ(device->last_values[1], 0x0304); + } + EXPECT_TRUE(uart.written.empty()); +} + +// A read broadcast is meaningless (it would need a reply), so nothing is dispatched and nothing is sent. +TEST(ModbusBroadcast, ReadFunctionCodeIsIgnoredAndProducesNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // FC 0x03 payload: start 0x0000, quantity 2. Reads cannot be broadcast. + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x02}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::READ_HOLDING_REGISTERS), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.write_count, 0); // no device was written + EXPECT_TRUE(uart.written.empty()); // and the broadcast address is never answered +} + +// An invalid broadcast write is silently dropped: no writes dispatched and no exception reply sent. +TEST(ModbusBroadcast, InvalidMultipleWriteBroadcastProducesNoWriteAndNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device_a(0x02); + RecordingDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x10 payload: quantity 2 but byte count 2 (should be 4), so parsing fails. + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0x02, 0x02, 0x01, 0x02}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device_a.write_count, 0); + EXPECT_EQ(device_b.write_count, 0); + EXPECT_TRUE(uart.written.empty()); +} + +// A device that rejects a broadcast write must not stop dispatch to devices registered after it, and the +// broadcast is still never answered. +TEST(ModbusBroadcast, RejectingDeviceDoesNotStopBroadcastDispatch) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RejectingDevice rejecter(0x02); + RecordingDevice device(0x03); + hub.register_device(&rejecter); // registered first, so a rejection happens before the normal device + hub.register_device(&device); + + // FC 0x06 payload: start address 0x9D31, value 0x00A5 (big-endian, no address/CRC). + const uint8_t pdu_data[] = {0x9D, 0x31, 0x00, 0xA5}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_REGISTER), pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(rejecter.write_count, 1); // the rejecting device was still invoked + EXPECT_EQ(device.write_count, 1); // and dispatch continued to the device registered after it + EXPECT_EQ(device.last_start_address, 0x9D31); + ASSERT_EQ(device.last_values.size(), 1u); + EXPECT_EQ(device.last_values[0], 0x00A5); + EXPECT_TRUE(uart.written.empty()); // a broadcast is never answered, even when a device rejects +} + +// A unicast out-of-range write sends exactly one exception frame on the wire. +TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingDevice device(0x02); + hub.register_device(&device); + + // FC 0x10 payload: start 0xFFFF, quantity 2, byte count 4, values valid but address range overflows. + const uint8_t pdu_data[] = {0xFF, 0xFF, 0x00, 0x02, 0x04, 0x01, 0x02, 0x03, 0x04}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.write_count, 0); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[0], 0x02); // server address + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_REGISTERS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); +} + +} // namespace esphome::modbus From 68acc055bf11f2993ec40c6be4529a9e6babc863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Sat, 8 Aug 2026 10:06:40 +0300 Subject: [PATCH 033/597] [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 13: xiaomi_rtcgq02lm, xiaomi_wx08zm, xiaomi_xmwsdj04mmc) (#18183) --- .../components/xiaomi_rtcgq02lm/__init__.py | 14 ++++---- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp | 6 +--- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h | 10 ++---- .../components/xiaomi_wx08zm/binary_sensor.py | 12 +++---- .../xiaomi_wx08zm/xiaomi_wx08zm.cpp | 6 +--- .../components/xiaomi_wx08zm/xiaomi_wx08zm.h | 10 ++---- .../components/xiaomi_xmwsdj04mmc/sensor.py | 14 ++++---- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp | 6 +--- .../xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h | 10 ++---- .../xiaomi_rtcgq02lm/common-ln.yaml | 20 ++++++++++++ tests/components/xiaomi_rtcgq02lm/common.yaml | 3 ++ .../xiaomi_rtcgq02lm/test.ln882x-ard.yaml | 3 ++ .../xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml | 32 +++++++++++++++++++ tests/components/xiaomi_wx08zm/common-ln.yaml | 8 +++++ tests/components/xiaomi_wx08zm/common.yaml | 3 ++ .../xiaomi_wx08zm/test.ln882x-ard.yaml | 3 ++ .../xiaomi_wx08zm/validate.bk72xx-ard.yaml | 20 ++++++++++++ .../xiaomi_xmwsdj04mmc/common-ln.yaml | 10 ++++++ .../components/xiaomi_xmwsdj04mmc/common.yaml | 3 ++ .../xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml | 3 ++ .../validate.bk72xx-ard.yaml | 24 ++++++++++++++ 21 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 tests/components/xiaomi_rtcgq02lm/common-ln.yaml create mode 100644 tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_wx08zm/common-ln.yaml create mode 100644 tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index df143bac22..3e235d985f 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -1,19 +1,19 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["esp32_ble_tracker"] MULTI_CONF = True xiaomi_rtcgq02lm_ns = cg.esphome_ns.namespace("xiaomi_rtcgq02lm") XiaomiRTCGQ02LM = xiaomi_rtcgq02lm_ns.class_( - "XiaomiRTCGQ02LM", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiRTCGQ02LM", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_rtcgq02lm"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiRTCGQ02LM), @@ -21,15 +21,15 @@ CONFIG_SCHEMA = ( cv.Required(CONF_MAC_ADDRESS): cv.mac_address, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index b42a5a3700..f349dfa797 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_rtcgq02lm { static const char *const TAG = "xiaomi_rtcgq02lm"; @@ -24,7 +22,7 @@ void XiaomiRTCGQ02LM::dump_config() { #endif } -bool XiaomiRTCGQ02LM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiRTCGQ02LM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -79,5 +77,3 @@ bool XiaomiRTCGQ02LM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) void XiaomiRTCGQ02LM::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_rtcgq02lm - -#endif diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index 0d3427cc4d..d776c22d9e 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/core/defines.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" @@ -11,16 +11,14 @@ #include "esphome/components/xiaomi_ble/xiaomi_ble.h" #include "esphome/core/component.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; #ifdef USE_BINARY_SENSOR @@ -54,5 +52,3 @@ class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTD }; } // namespace esphome::xiaomi_rtcgq02lm - -#endif diff --git a/esphome/components/xiaomi_wx08zm/binary_sensor.py b/esphome/components/xiaomi_wx08zm/binary_sensor.py index 69facf54ed..6aaf94f48f 100644 --- a/esphome/components/xiaomi_wx08zm/binary_sensor.py +++ b/esphome/components/xiaomi_wx08zm/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker, sensor +from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -12,18 +12,18 @@ from esphome.const import ( UNIT_PERCENT, ) -DEPENDENCIES = ["esp32_ble_tracker"] -AUTO_LOAD = ["xiaomi_ble", "sensor"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble", "sensor"] xiaomi_wx08zm_ns = cg.esphome_ns.namespace("xiaomi_wx08zm") XiaomiWX08ZM = xiaomi_wx08zm_ns.class_( "XiaomiWX08ZM", binary_sensor.BinarySensor, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, cg.Component, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_wx08zm"), binary_sensor.binary_sensor_schema(XiaomiWX08ZM) .extend( { @@ -43,15 +43,15 @@ CONFIG_SCHEMA = cv.All( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index 1bf861a6af..ae37d63096 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -1,8 +1,6 @@ #include "xiaomi_wx08zm.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_wx08zm { static const char *const TAG = "xiaomi_wx08zm"; @@ -14,7 +12,7 @@ void XiaomiWX08ZM::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiWX08ZM::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -56,5 +54,3 @@ bool XiaomiWX08ZM::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::xiaomi_wx08zm - -#endif diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0573959473..bbb7b66352 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -3,20 +3,18 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_wx08zm { class XiaomiWX08ZM final : public Component, public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { + public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_tablet(sensor::Sensor *tablet) { tablet_ = tablet; } @@ -29,5 +27,3 @@ class XiaomiWX08ZM final : public Component, }; } // namespace esphome::xiaomi_wx08zm - -#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index b41a775f35..758fa53d9e 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -17,16 +17,16 @@ from esphome.const import ( UNIT_PERCENT, ) -AUTO_LOAD = ["xiaomi_ble"] +AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@medusalix"] -DEPENDENCIES = ["esp32_ble_tracker"] xiaomi_xmwsdj04mmc_ns = cg.esphome_ns.namespace("xiaomi_xmwsdj04mmc") XiaomiXMWSDJ04MMC = xiaomi_xmwsdj04mmc_ns.class_( - "XiaomiXMWSDJ04MMC", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "XiaomiXMWSDJ04MMC", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_xmwsdj04mmc"), cv.Schema( { cv.GenerateID(): cv.declare_id(XiaomiXMWSDJ04MMC), @@ -53,15 +53,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index c2b3ec1437..aba954fd91 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -2,8 +2,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_xmwsdj04mmc { static const char *const TAG = "xiaomi_xmwsdj04mmc"; @@ -21,7 +19,7 @@ void XiaomiXMWSDJ04MMC::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool XiaomiXMWSDJ04MMC::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -69,5 +67,3 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic void XiaomiXMWSDJ04MMC::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } } // namespace esphome::xiaomi_xmwsdj04mmc - -#endif diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index c7d20aa356..90b2c4e420 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -2,19 +2,17 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/xiaomi_ble/xiaomi_ble.h" -#ifdef USE_ESP32 - namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } @@ -30,5 +28,3 @@ class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPB }; } // namespace esphome::xiaomi_xmwsdj04mmc - -#endif diff --git a/tests/components/xiaomi_rtcgq02lm/common-ln.yaml b/tests/components/xiaomi_rtcgq02lm/common-ln.yaml new file mode 100644 index 0000000000..4a04476457 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/common-ln.yaml @@ -0,0 +1,20 @@ +xiaomi_rtcgq02lm: + - id: motion_rtcgq02lm + mac_address: 01:02:03:04:05:06 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + +binary_sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + motion: + name: Mi Motion Sensor 2 + light: + name: Mi Motion Sensor 2 Light + button: + name: Mi Motion Sensor 2 Button + +sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + battery_level: + name: Mi Motion Sensor 2 Battery level diff --git a/tests/components/xiaomi_rtcgq02lm/common.yaml b/tests/components/xiaomi_rtcgq02lm/common.yaml index a2e0c66ba5..4d235f6813 100644 --- a/tests/components/xiaomi_rtcgq02lm/common.yaml +++ b/tests/components/xiaomi_rtcgq02lm/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub +# Explicit ble_hub_id: pins the neutral binding as a declared key. xiaomi_rtcgq02lm: - id: motion_rtcgq02lm + ble_hub_id: ble_tracker_hub mac_address: 01:02:03:04:05:06 bindkey: "48403ebe2d385db8d0c187f81e62cb64" diff --git a/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml b/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml new file mode 100644 index 0000000000..6ef79a6626 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_rtcgq02lm: !include common-ln.yaml diff --git a/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml b/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..9c67182050 --- /dev/null +++ b/tests/components/xiaomi_rtcgq02lm/validate.bk72xx-ard.yaml @@ -0,0 +1,32 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +# Explicit ble_hub_id: pins the neutral binding as a declared key. +xiaomi_rtcgq02lm: + - id: motion_rtcgq02lm + ble_hub_id: ble_tracker_hub + mac_address: 01:02:03:04:05:06 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" + + # No ble_hub_id: exercises the generated binding real configs use. + - id: motion_rtcgq02lm_implicit + mac_address: 01:02:03:04:05:07 + bindkey: "48403ebe2d385db8d0c187f81e62cb64" +binary_sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + motion: + name: Mi Motion Sensor 2 + light: + name: Mi Motion Sensor 2 Light + button: + name: Mi Motion Sensor 2 Button + +sensor: + - platform: xiaomi_rtcgq02lm + id: motion_rtcgq02lm + battery_level: + name: Mi Motion Sensor 2 Battery level diff --git a/tests/components/xiaomi_wx08zm/common-ln.yaml b/tests/components/xiaomi_wx08zm/common-ln.yaml new file mode 100644 index 0000000000..83766c084b --- /dev/null +++ b/tests/components/xiaomi_wx08zm/common-ln.yaml @@ -0,0 +1,8 @@ +binary_sensor: + - platform: xiaomi_wx08zm + name: WX08ZM Activation State + mac_address: 74:a3:4a:b5:07:34 + tablet: + name: WX08ZM Tablet Resource + battery_level: + name: WX08ZM Battery Level diff --git a/tests/components/xiaomi_wx08zm/common.yaml b/tests/components/xiaomi_wx08zm/common.yaml index 3e83ad3e95..6e43a92d2e 100644 --- a/tests/components/xiaomi_wx08zm/common.yaml +++ b/tests/components/xiaomi_wx08zm/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_wx08zm + ble_hub_id: ble_tracker_hub name: WX08ZM Activation State mac_address: 74:a3:4a:b5:07:34 tablet: diff --git a/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml b/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml new file mode 100644 index 0000000000..81f05c0c7b --- /dev/null +++ b/tests/components/xiaomi_wx08zm/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_wx08zm: !include common-ln.yaml diff --git a/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml b/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..fb9a4e3652 --- /dev/null +++ b/tests/components/xiaomi_wx08zm/validate.bk72xx-ard.yaml @@ -0,0 +1,20 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +binary_sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_wx08zm + ble_hub_id: ble_tracker_hub + name: WX08ZM Activation State + mac_address: 74:a3:4a:b5:07:34 + tablet: + name: WX08ZM Tablet Resource + battery_level: + name: WX08ZM Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_wx08zm + name: BK WX08ZM Implicit Activation State + mac_address: 74:a3:4a:b5:07:35 diff --git a/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml b/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml new file mode 100644 index 0000000000..2a0778c2a7 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/common-ln.yaml @@ -0,0 +1,10 @@ +sensor: + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level diff --git a/tests/components/xiaomi_xmwsdj04mmc/common.yaml b/tests/components/xiaomi_xmwsdj04mmc/common.yaml index fe7a11efc5..1de13b2bc5 100644 --- a/tests/components/xiaomi_xmwsdj04mmc/common.yaml +++ b/tests/components/xiaomi_xmwsdj04mmc/common.yaml @@ -1,7 +1,10 @@ esp32_ble_tracker: + id: ble_tracker_hub sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. - platform: xiaomi_xmwsdj04mmc + ble_hub_id: ble_tracker_hub mac_address: 84:B4:DB:5D:A3:8F bindkey: d8ca2ed09bb5541dc8f045ca360b00ea temperature: diff --git a/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml new file mode 100644 index 0000000000..749473a022 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_xmwsdj04mmc: !include common-ln.yaml diff --git a/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml b/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..4139263b52 --- /dev/null +++ b/tests/components/xiaomi_xmwsdj04mmc/validate.bk72xx-ard.yaml @@ -0,0 +1,24 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_xmwsdj04mmc + ble_hub_id: ble_tracker_hub + mac_address: 84:B4:DB:5D:A3:8F + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: Xiaomi XMWSDJ04MMC Temperature + humidity: + name: Xiaomi XMWSDJ04MMC Humidity + battery_level: + name: Xiaomi XMWSDJ04MMC Battery Level + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_xmwsdj04mmc + mac_address: 84:B4:DB:5D:A3:90 + bindkey: d8ca2ed09bb5541dc8f045ca360b00ea + temperature: + name: BK Xiaomi XMWSDJ04MMC Implicit Temperature From 747c5c3e405a0e1d6ce26d1477ce17277a569d7c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Aug 2026 16:34:59 +0200 Subject: [PATCH 034/597] [modbus] Make sure we log on no accepting device (#18187) --- esphome/components/modbus/modbus.cpp | 22 ++++++++++++++++++++-- esphome/components/modbus/modbus.h | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 87aace02d0..c9e443cd87 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -15,6 +15,9 @@ static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; // Milliseconds per second static constexpr uint32_t MS_PER_SEC = 1000; +// Shortest gap between two "no device accepted broadcast" warnings +static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC; + void Modbus::setup() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); @@ -445,13 +448,28 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< if (status.has_value()) { return; } + // A broadcast is never answered, so a rejecting device has no other feedback channel: report the + // per-device outcome at V, and warn if the write reached nobody at all. + bool accepted = false; for (auto *device : this->devices_) { - // A broadcast is never answered, so a rejecting device has no other feedback channel; log it so a - // misconfigured register map is diagnosable instead of looking identical to a successful write. if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers); device_status.has_value()) { ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), static_cast(device_status.value())); + } else { + accepted = true; + } + } + if (!accepted && !this->devices_.empty()) { + // Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes + // repeats forever, so warning per frame would flood the log. + const uint32_t now = millis(); + if (this->last_unaccepted_broadcast_warn_ == 0 || + now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) { + this->last_unaccepted_broadcast_warn_ = now; + ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); + } else { + ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); } } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 5a700912de..274b10f9b4 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -361,6 +361,10 @@ class ModbusServerHub : public Modbus { uint8_t expecting_peer_response_{0}; std::vector devices_; + // Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting + // on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries. + uint32_t last_unaccepted_broadcast_warn_{0}; + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. // Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation. std::array deferred_payload_; From 9cb46aa5846386d34fee2b33814618cf089b0dfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 09:50:32 -0500 Subject: [PATCH 035/597] [bluetooth_proxy] Deliver esp32 advertisements through the hub callback (#18173) --- .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 2 +- .../components/ble_device_base/ble_device.h | 9 +- esphome/components/ble_device_base/ble_hub.h | 5 +- .../bluetooth_connection_esp32.cpp | 4 +- .../components/bluetooth_proxy/__init__.py | 6 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 150 +++++------------- .../bluetooth_proxy/bluetooth_proxy.h | 21 +-- .../components/esp32_ble_tracker/__init__.py | 14 -- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 11 ++ .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 7 +- .../rp2_ble_tracker/rp2_ble_tracker.cpp | 2 +- .../ble_device_base/test_slot_counter.py | 7 +- .../ble_device_base/test_raw_callback.cpp | 8 +- 13 files changed, 95 insertions(+), 151 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index c859f22c61..a58561f2de 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -159,7 +159,7 @@ void BK72xxBLETracker::dump_config() { void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { // Raw callback (the raw-advertisement path). if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.mac = report.mac, + const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), .data = report.data, .data_len = report.data_len, .rssi = report.rssi, diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index fba1fe2347..b5f198375c 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -154,12 +154,9 @@ class ESPBLEiBeacon { }; /// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks. -/// -/// The result is the printable-order value esp32 has always sent -/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire. -/// This takes the raw controller order delivered by BLEHub's raw-advertisement -/// callback; ESPBTDevice::address_uint64() is the equivalent for an already -/// parsed device, whose address is stored MSB-first. +/// Trackers with LSB-native SDKs call this at the emit site before filling +/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent +/// for an already parsed device, whose address is stored MSB-first. inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { uint64_t addr = 0; for (int i = 0; i < 6; i++) diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index b6fcf6f57a..d9a7731504 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -23,8 +23,9 @@ namespace esphome::ble_device_base { /// One raw advertisement as delivered by the controller — a borrowed view, /// valid only for the duration of the invoke() callback. struct RawAdvertisement { - /// Least-significant octet first (BLE controller convention). - const uint8_t *mac; + /// Producers convert their native byte order at the emit site, so no + /// byte-order convention crosses this contract. + uint64_t address; const uint8_t *data; uint16_t data_len; int8_t rssi; // signed dBm diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 7c62d3766c..be6fa4c6c5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -480,7 +480,9 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl } esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - return this->proxy_->get_advertisement_parser_type(); + // RAW keeps the tracker from building parsed ESPBTDevice objects for the + // proxy's connections (the proxy itself consumes the hub raw callback). + return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 057b15193a..4aa4195ff9 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,7 +374,11 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - await esp32_ble_tracker.register_raw_ble_device(var, config) + # Advertisements arrive through the hub raw callback (installed in + # setup()); only the scanner-state listener still registers with the + # tracker directly. + tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) + cg.add(var.set_parent(tracker)) await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 06e3b9a3b4..19e894600e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -8,6 +8,7 @@ #include "esphome/core/macros.h" #include "esphome/core/application.h" #include +#include #include #include @@ -26,14 +27,6 @@ BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } #ifdef USE_ESP32 -void BluetoothProxy::setup() { - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; - this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; - - // Capture the configured scan mode from YAML before any API changes - this->configured_scan_active_ = this->parent_->get_scan_active(); -} - void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -43,8 +36,8 @@ void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); - resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; @@ -53,45 +46,6 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta #else // !USE_ESP32 -void BluetoothProxy::setup() { - // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; - this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; - - // Capture the configured scan mode from YAML before any API changes - this->configured_scan_active_ = this->hub_->scan_active(); - - // The hub delivers raw advertisements on the ESPHome main loop: - // mac is least-significant octet first (BLE controller convention). - this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { - static_cast(self)->on_raw_advertisement_(adv); - }}); -} - -void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) - return; - - auto &adv = this->response_.advertisements[this->response_.advertisements_len]; - // raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends. - adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac); - adv.rssi = raw.rssi; - adv.address_type = raw.addr_type; - uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); - adv.data_len = length; - std::memcpy(adv.data, raw.data, length); - - this->response_.advertisements_len++; - - ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4], - raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi); - - // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); - } -} - void BluetoothProxy::send_bluetooth_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -112,6 +66,42 @@ void BluetoothProxy::send_bluetooth_scanner_state_() { #endif // USE_ESP32 +void BluetoothProxy::setup() { + // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; + + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->hub_->scan_active(); + + this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { + static_cast(self)->on_raw_advertisement_(adv); + }}); +} + +// The hub delivers raw advertisements on the ESPHome main loop. +void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + return; + + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; + adv.address = raw.address; + adv.rssi = raw.rssi; + adv.address_type = raw.addr_type; + uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); + adv.data_len = length; + std::memcpy(adv.data, raw.data, length); + + this->response_.advertisements_len++; + + ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + this->flush_pending_advertisements_(); + } +} + #ifdef BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), @@ -133,50 +123,6 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); } -#ifdef USE_ESP32 - -#ifdef USE_ESP32_BLE_DEVICE -bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { - // This method should never be called since bluetooth_proxy always uses raw advertisements - // but we need to provide an implementation to satisfy the virtual method requirement - return false; -} -#endif - -bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) - return false; - - auto &advertisements = this->response_.advertisements; - - for (size_t i = 0; i < count; i++) { - auto &result = scan_results[i]; - uint8_t length = result.adv_data_len + result.scan_rsp_len; - - // Fill in the data directly at current position - auto &adv = advertisements[this->response_.advertisements_len]; - adv.address = esp32_ble::ble_addr_to_uint64(result.bda); - adv.rssi = result.rssi; - adv.address_type = result.ble_addr_type; - adv.data_len = length; - std::memcpy(adv.data, result.ble_adv, length); - - this->response_.advertisements_len++; - - ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], - result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); - - // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); - } - } - - return true; -} - -#endif // USE_ESP32 - void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } @@ -236,10 +182,6 @@ void BluetoothProxy::loop() { } } -esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; -} - #endif // USE_ESP32 #ifdef BLUETOOTH_CONNECTION_HAS_GATT @@ -522,13 +464,13 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #ifdef USE_ESP32 void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_->get_scan_active() == active) { + if (this->parent_()->get_scan_active() == active) { return; } ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_->set_scan_active(active); - this->parent_->stop_scan(); - this->parent_->set_scan_continuous( + this->parent_()->set_scan_active(active); + this->parent_()->stop_scan(); + this->parent_()->set_scan_continuous( true); // Set this to true to automatically start scanning again when it has cleaned up. } @@ -675,8 +617,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection } this->api_connection_ = api_connection; #ifdef USE_ESP32 - this->parent_->recalculate_advertisement_parser_types(); - this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); + this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); #else this->send_bluetooth_scanner_state_(); #endif @@ -688,9 +629,6 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; -#ifdef USE_ESP32 - this->parent_->recalculate_advertisement_parser_types(); -#endif } void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b8c8ab15f6..ed39a697aa 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -73,9 +73,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; #ifdef USE_ESP32 -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, - public esp32_ble_tracker::BLEScannerStateListener, - public Component { +class BluetoothProxy final : public esp32_ble_tracker::BLEScannerStateListener, public Component { #else class BluetoothProxy final : public Component { #endif @@ -86,11 +84,9 @@ class BluetoothProxy final : public Component { public: BluetoothProxy(); #ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_DEVICE - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; -#endif - bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; + // Advertisements arrive through the hub's raw callback; parent_() below + // recovers the tracker type for the esp32-only scan-mode calls. + void set_parent(esp32_ble_tracker::ESP32BLETracker *parent) { this->hub_ = parent; } #endif // USE_ESP32 void dump_config() override; void setup() override; @@ -221,8 +217,8 @@ class BluetoothProxy final : public Component { void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); #else void send_bluetooth_scanner_state_(); - void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); #endif + void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); /// Caller must ensure api_connection_ is non-null and API server is connected. void flush_pending_advertisements_() { @@ -288,8 +284,13 @@ class BluetoothProxy final : public Component { // Group 2: Fixed-size array of connection pointers std::array connections_{}; #endif -#ifndef USE_ESP32 ble_device_base::BLEHub *hub_{nullptr}; +#ifdef USE_ESP32 + // set_parent() is the only writer of hub_ on esp32, so the downcast is + // exact; ESP32BLETracker derives from BLEHub non-virtually. + esp32_ble_tracker::ESP32BLETracker *parent_() { + return static_cast(this->hub_); + } #endif // BLE advertisement batching diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b8f49d4fbd..646ce79233 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -374,20 +374,6 @@ async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExp return var -async def register_raw_ble_device( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a BLE device listener that only needs raw advertisement data. - - This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice - will not be compiled in if this is the only registration method used. - """ - _request_listener_slot() - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.register_listener(var)) - return var - - async def register_raw_client( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8418fc3fec..cec2f230f8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -462,6 +462,17 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { #endif // USE_ESP32_BLE_DEVICE void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { + // Neutral raw-advertisement subscriber (the bluetooth_proxy path). + if (this->raw_advertisement_callback_.is_set()) { + ble_device_base::RawAdvertisement adv; + adv.address = esp32_ble::ble_addr_to_uint64(scan_result.bda); + adv.data = scan_result.ble_adv; + adv.data_len = static_cast(scan_result.adv_data_len) + scan_result.scan_rsp_len; + adv.rssi = scan_result.rssi; + adv.addr_type = scan_result.ble_addr_type; + this->raw_advertisement_callback_.invoke(adv); + } + // Process raw advertisements if (this->raw_advertisements_) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index 90be341820..cddcd6c17d 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -240,8 +240,11 @@ void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t add // Raw callback (the raw-advertisement path). Both full advertisements and // unmatched scan responses (raw_only) are forwarded. if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{ - .mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type}; + const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; this->raw_advertisement_callback_.invoke(adv); } diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index ed036328ae..c2bb93a32e 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -122,7 +122,7 @@ void RP2BLETracker::dump_config() { void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { // Raw callback (the raw-advertisement path). if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.mac = report.mac, + const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), .data = report.data, .data_len = report.data_len, .rssi = report.rssi, diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index 0fa5577a0b..1c1499cb2d 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -107,14 +107,15 @@ def test_esp32_bluetooth_proxy_requests_scanner_state_slot( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The proxy requests one scanner state slot, one raw listener slot and a - client slot per connection (three by default with active: true).""" + """The proxy requests one scanner state slot and a client slot per + connection (three by default with active: true); advertisements arrive + through the hub raw callback, so no listener slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert ( get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") == "1" ) - assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp index cd18c3db59..4d72c8fb18 100644 --- a/tests/components/ble_device_base/test_raw_callback.cpp +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -46,13 +46,13 @@ struct CapturingSubscriber { } }; -// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first. -const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa}; +// Device AA:BB:CC:DD:EE:FF, packed the way the API speaks it. +constexpr uint64_t TEST_ADDRESS = 0xAABBCCDDEEFFULL; const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00}; RawAdvertisement make_test_adv() { return RawAdvertisement{ - .mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1}; + .address = TEST_ADDRESS, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1}; } } // namespace @@ -70,7 +70,7 @@ TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) { hub.emit(make_test_adv()); ASSERT_EQ(subscriber.calls, 1); - EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST); + EXPECT_EQ(subscriber.last.address, TEST_ADDRESS); EXPECT_EQ(subscriber.last.data, ADV_DATA); EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA)); EXPECT_EQ(subscriber.last.rssi, -63); From 7218aa4803a7f38cdf8fc6bc2ea1903ab751ce3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 09:51:12 -0500 Subject: [PATCH 036/597] [bluetooth_connection] Explicit pairing for rp2 (#18166) --- .../ble_device_base/ble_gatt_client.h | 3 + .../bluetooth_connection.h | 15 +++- .../bluetooth_connection_hub.cpp | 11 +++ .../bluetooth_connection_hub.h | 5 ++ .../bluetooth_connection_rp2.cpp | 74 +++++++++++++++++++ .../bluetooth_connection_rp2.h | 5 ++ .../bluetooth_proxy/bluetooth_proxy.cpp | 18 +++-- .../esp32_ble_client/ble_client_base.h | 2 + .../test_gatt_client_contract.cpp | 10 +++ .../bluetooth_connection/__init__.py | 18 +++++ .../test_close_service_batch.cpp | 58 +++++++++++++++ 11 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 tests/components/bluetooth_connection/__init__.py create mode 100644 tests/components/bluetooth_connection/test_close_service_batch.cpp diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 1bcfcf99dc..37edc570ec 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -95,6 +95,7 @@ class GattClientEventListener { virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; /// Notification/indication data from the peer. data/len valid during the call. virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; + virtual void on_pairing_result(int status) {} }; /// One GATT client connection slot. Operations return 0 when accepted @@ -123,6 +124,8 @@ class BLEGattConnection { /// handle. Local registration only — the CCCD write is the API client's /// responsibility (it arrives as a plain write_descriptor). virtual int notify_characteristic(uint16_t handle, bool enable) = 0; + /// Initiate pairing on the live link. Completion: on_pairing_result(). + virtual int pair() { return GATT_ERR_NOT_CONNECTED; } virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) = 0; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 712251b157..2125d5b34f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -48,20 +48,29 @@ static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_C // What the platform's connection backend supports beyond GATT operations; // the proxy derives its feature flags and legacy version from these. -#ifdef USE_ESP32 +#if defined(USE_ESP32) static constexpr bool SUPPORTS_PAIRING = true; static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) +// The rp2 BTstack backend pairs (just works + bonding); it has no service +// cache to clear. Keyed on the backend, not the generic client define, so a +// future backend without pairing keeps the stub arm below. +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; #else static constexpr bool SUPPORTS_PAIRING = false; static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#ifdef USE_ESP32 +#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) conn_err_t unpair_device(uint64_t address); -conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +#endif +#ifdef USE_ESP32 +conn_err_t clear_gatt_cache(uint64_t address); +#else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 37d6b21dfe..b69a07fc31 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -74,6 +74,16 @@ void BluetoothConnection::check_disconnect_timeout_() { } } +void BluetoothConnection::on_pairing_result(int status) { + if (this->address_ == 0) { + // A drop before completion already answered: reset_connection_slot_ sends + // the connection response, which the client's pair watcher raises on. + return; + } + this->paired_ = status == 0; + this->proxy_->send_device_pairing(this->address_, status == 0, status); +} + void BluetoothConnection::reset_connection_(conn_err_t reason) { if (this->pending_error_ != 0) { reason = this->pending_error_; @@ -81,6 +91,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { } this->state_ = ClientState::IDLE; this->services_discovered_ = false; + this->paired_ = false; this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 83fbd24e4c..34e400ac01 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -49,6 +49,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->start_connect_(); } void disconnect(); + bool is_paired() const { return this->paired_; } + void set_unpaired() { this->paired_ = false; } + conn_err_t pair() { return this->backend_->pair(); } // A backend disconnect() is a single call that also cancels an in-progress // connect; there is no deferred-disconnect state to track. bool disconnect_pending() const { return false; } @@ -87,6 +90,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene void on_write_result(uint16_t handle, int error) override; void on_notify_state(uint16_t handle, bool enabled, int error) override; void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; protected: friend class bluetooth_proxy::BluetoothProxy; @@ -118,6 +122,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene // Group 5: 1-byte types ClientState state_{ClientState::IDLE}; + bool paired_{false}; ConnectionType connection_type_{ConnectionType::V1}; uint8_t remote_addr_type_{0}; uint8_t connection_index_{0}; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 5eb3da0263..cd7577e7f7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,4 +1,5 @@ #include "bluetooth_connection_rp2.h" +#include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -51,6 +52,7 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -88,6 +90,8 @@ void RP2GattClient::setup() { if (hci_event_registration.callback == nullptr) { hci_event_registration.callback = &RP2GattClient::hci_packet_handler; hci_add_event_handler(&hci_event_registration); + sm_event_registration.callback = &RP2GattClient::sm_packet_handler; + sm_add_event_handler(&sm_event_registration); } } @@ -157,6 +161,38 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * } } +void RP2GattClient::sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_JUST_WORKS_REQUEST: + // Confirming from the SM callback is the intended BTstack pattern. + // Unscoped on purpose: no peripheral role exists in-tree, and scoping + // would drop a request racing the queued CONNECTED event. + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_PAIRING_COMPLETE: { + RP2GattClient *inst = instance_for_con_handle(sm_event_pairing_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_pairing_complete_get_status(packet), 0); + } + break; + } + case SM_EVENT_REENCRYPTION_COMPLETE: { + // A bonded peer re-encrypts instead of pairing; BTstack emits only this + // event on that path, so it answers the PAIR request too. + RP2GattClient *inst = instance_for_con_handle(sm_event_reencryption_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_reencryption_complete_get_status(packet), 0); + } + break; + } + default: + break; + } +} + void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { if (type != HCI_EVENT_PACKET) { return; @@ -447,6 +483,11 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::WRITE_NO_RSP_DONE: this->finish_write_no_rsp_(event.status); break; + case RP2GattEvent::PAIRING_RESULT: + if (this->listener_ != nullptr) { + this->listener_->on_pairing_result(event.status); + } + break; } } @@ -1019,6 +1060,15 @@ int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16 return 0; } +int RP2GattClient::pair() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + sm_request_pairing(this->con_handle_); // void API; completion via SM events + return 0; +} + int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { if (this->state_ != EngineState::READY) { return GATT_ERR_NOT_CONNECTED; @@ -1064,6 +1114,30 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); } +conn_err_t unpair_device(uint64_t address) { + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + bool found = false; + BluetoothLock lock; + // Exhaustive: the db keys on (type, address), so stale entries can share + // the same address bytes under different types. + for (int i = 0; i < le_device_db_max_count(); i++) { + int addr_type = 0; + bd_addr_t addr; + le_device_db_info(i, &addr_type, addr, nullptr); + if (addr_type != BD_ADDR_TYPE_UNKNOWN && memcmp(addr, mac, sizeof(bd_addr_t)) == 0) { + le_device_db_remove(i); + found = true; + } + } + if (found) { + return CONN_OK; + } + // No bond for this address; the shared error domain has no closer code + // (esp32 parity: its remove-bond call also errors for an unknown address). + return GATT_NOT_CONNECTED; +} + } // namespace esphome::bluetooth_connection #endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 0c1bc95fe9..1a3671354d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -51,6 +51,7 @@ struct RP2GattEvent { MTU_EXCHANGED, // value = negotiated MTU QUERY_COMPLETE, // status = ATT status of the finished query WRITE_NO_RSP_DONE, // status = result of the deferred write + PAIRING_RESULT, // status = SM pairing status (0 = bonded) }; Type type; uint8_t status; @@ -89,6 +90,7 @@ class RP2GattClient final : public Component, int read_descriptor(uint16_t handle) override; int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; int notify_characteristic(uint16_t handle, bool enable) override; + int pair() override; int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) override; ble_device_base::GattServiceTable get_service_table() override; @@ -120,6 +122,7 @@ class RP2GattClient final : public Component, // BTstack packet handlers (IRQ context: copy-and-enqueue only). static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); @@ -205,6 +208,8 @@ class RP2GattClient final : public Component, static uint8_t instance_count; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static btstack_packet_callback_registration_t hci_event_registration; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t sm_event_registration; }; } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 19e894600e..56b79fe1b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -311,12 +311,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { -#ifdef USE_ESP32 + // Both connection classes expose the same pairing surface; success is + // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { auto err = connection->pair(); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_device_pairing(msg.address, false, err); } } else { @@ -326,15 +327,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest // Answer instead of leaving the client to time out. this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } -#else - // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); - // peripheral-initiated security still works through the platform's SM. - this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); -#endif break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + if (ret == CONN_OK) { + // The bond is gone; a live connection must not short-circuit the + // next PAIR as already paired. + auto *connection = this->get_connection_(msg.address, false); + if (connection != nullptr) { + connection->set_unpaired(); + } + } this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0902aad924..e4b9cd5100 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -92,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint16_t get_conn_id() const { return this->conn_id_; } uint64_t get_address() const { return this->address_; } bool is_paired() const { return this->paired_; } + // The proxy clears this when a bond is removed while the link is up. + void set_unpaired() { this->paired_ = false; } uint8_t get_connection_index() const { return this->connection_index_; } diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index fb743b2699..b4491295db 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -46,6 +46,16 @@ class MinimalConnection : public BLEGattConnection { void release_services() override {} }; +TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) { + // pair() defaults to not-connected and on_pairing_result() to a no-op, so + // a backend without pairing still answers the client through the dispatch. + MinimalConnection conn; + RecordingListener listener; + conn.set_listener(&listener); + EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED); + listener.on_pairing_result(0); // must not crash: default body +} + TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; RecordingListener listener; diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..eae98931ec --- /dev/null +++ b/tests/components/bluetooth_connection/__init__.py @@ -0,0 +1,18 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; + # emit the backend define so the host build exercises it. + async def to_code_testing(config): + # These defines are global to the merged host test binary; safe + # because no co-compiled test observes them. + cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) + + manifest.to_code = to_code_testing + # The batcher sizes api protobuf messages. + manifest.dependencies = manifest.dependencies + ["api"] diff --git a/tests/components/bluetooth_connection/test_close_service_batch.cpp b/tests/components/bluetooth_connection/test_close_service_batch.cpp new file mode 100644 index 0000000000..601ff9202b --- /dev/null +++ b/tests/components/bluetooth_connection/test_close_service_batch.cpp @@ -0,0 +1,58 @@ +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +#include "esphome/components/api/api_pb2.h" + +namespace esphome::bluetooth_connection { + +// The three cursor behaviors: a fitting service advances and continues, an +// overflowing batch with >1 service pops and retries it, and a single +// oversized service is force-advanced so the stream cannot wedge. + +static void add_service(api::BluetoothGATTGetServicesResponse &resp, uint16_t characteristics) { + resp.services.emplace_back(); + auto &svc = resp.services.back(); + svc.handle = resp.services.size(); + svc.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + svc.characteristics.init(characteristics); + for (uint16_t i = 0; i < characteristics; i++) { + auto &chr = svc.characteristics.emplace_back(); + chr.handle = 100 + i; + chr.properties = 0x12; + chr.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + } +} + +TEST(CloseServiceBatch, FittingServiceAdvancesAndContinues) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + size_t current_size = 0; + int16_t cursor = 0; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::CONTINUE); + EXPECT_EQ(cursor, 1); + EXPECT_GT(current_size, 0u); +} + +TEST(CloseServiceBatch, OverflowPopsAndRetriesWithoutAdvancing) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + add_service(resp, 1); + size_t current_size = MAX_PACKET_SIZE - 10; // any service is bigger than 10 bytes + int16_t cursor = 5; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(resp.services.size(), 1u); // popped for the next batch + EXPECT_EQ(cursor, 5); // not advanced: retried next batch +} + +TEST(CloseServiceBatch, SingleOversizedServiceForceAdvances) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 60); // ~30 bytes per characteristic, far past the budget + ASSERT_GT(resp.services.back().calculate_size(), MAX_PACKET_SIZE); + size_t current_size = 0; + int16_t cursor = 7; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(cursor, 8); // advanced despite not fitting, so the stream moves on +} + +} // namespace esphome::bluetooth_connection From 04384e0f5bcfbdba770f7133583eb5a425761ced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:32 -0500 Subject: [PATCH 037/597] [ble_device_base] Bind the GATT backend at compile time (#18185) --- .../ble_device_base/ble_gatt_client.h | 118 ++++++++---------- esphome/components/ble_device_base/ble_hub.h | 5 +- .../bluetooth_connection_gatt_backend.h | 64 ++++++++++ .../bluetooth_connection_hub.cpp | 2 +- .../bluetooth_connection_hub.h | 32 ++--- .../bluetooth_connection_rp2.cpp | 2 + .../bluetooth_connection_rp2.h | 47 +++---- .../test_gatt_client_contract.cpp | 65 +++++----- .../bluetooth_connection/__init__.py | 1 + 9 files changed, 199 insertions(+), 137 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 37edc570ec..74548f578f 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -2,12 +2,13 @@ // // Platform-neutral GATT client connection contract. // -// A platform's GATT client backend (bluetooth_connection/esp32, -// bluetooth_connection/rp2) implements BLEGattConnection; consumers -// (bluetooth_proxy) drive it through this interface and receive -// completions through GattClientEventListener. All listener callbacks are -// delivered on the ESPHome main loop; borrowed data pointers are valid only -// for the duration of the call. +// Exactly one GATT backend exists per build, so BLEGattConnection is a +// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract +// interface. +// The hub BluetoothConnection wrapper drives it and receives completions +// through its event-sink methods, which the backend calls directly. All sink +// calls are delivered on the ESPHome main loop; borrowed data pointers are +// valid only for the duration of the call. // // Error domain (plain int, forwarded to the API without translation): // 0 success @@ -29,6 +30,7 @@ #include "ble_client_state.h" #include "ble_device.h" +#include #include namespace esphome::ble_device_base { @@ -76,67 +78,53 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; -/// Completion/event sink for a GATT connection. Implemented by the consumer -/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop. -class GattClientEventListener { - public: - virtual ~GattClientEventListener() = default; - - /// Connected (with negotiated MTU) or disconnected/connect-failed - /// (error = HCI status or disconnect reason). - virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0; - /// Service discovery finished; on success the service table is populated. - virtual void on_service_discovery_done(int error) = 0; - /// Characteristic or descriptor read finished. data/len valid during the call. - virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0; - /// Characteristic write-with-response or descriptor write finished. - virtual void on_write_result(uint16_t handle, int error) = 0; - /// Notification/indication registration state changed. - virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; - /// Notification/indication data from the peer. data/len valid during the call. - virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; - virtual void on_pairing_result(int status) {} +// The BLEGattConnection op surface, asserted where the alias binds +// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives +// through the sink) or a synchronous error (busy, not connected, stack +// rejection); one operation may be outstanding at a time. Semantics beyond +// the signatures: +// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). +// - disconnect: also cancels a connect in progress. +// - notify_characteristic: local registration only; the CCCD write is the +// API client's responsibility (a plain write_descriptor). +// - get_service_table/release_services: backend-owned transient storage, +// released after streaming (release is idempotent). +// - completions: connect and disconnect land in on_connection_state, +// discover_services in on_service_discovery_done, pair in +// on_pairing_result, reads in on_read_result, notify_characteristic in +// on_notify_state, characteristic writes with response and descriptor +// writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { + conn.set_listener(sink); + { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; + { conn.disconnect() } -> std::same_as; + { conn.discover_services() } -> std::same_as; + { conn.read_characteristic(uint16_t{}) } -> std::same_as; + { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; + { conn.read_descriptor(uint16_t{}) } -> std::same_as; + { conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { conn.notify_characteristic(uint16_t{}, true) } -> std::same_as; + { conn.pair() } -> std::same_as; + { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; + { conn.get_service_table() } -> std::same_as; + { conn.release_services() } -> std::same_as; }; -/// One GATT client connection slot. Operations return 0 when accepted -/// (completion arrives via the listener) or a synchronous error code -/// (busy, not connected, stack rejection). One operation may be outstanding -/// at a time; callers see a synchronous error otherwise. -class BLEGattConnection { - public: - virtual ~BLEGattConnection() = default; - - void set_listener(GattClientEventListener *listener) { this->listener_ = listener; } - - /// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant - /// (ble_device.h). Completion: on_connection_state(). - virtual int connect(uint64_t address, uint8_t addr_type) = 0; - /// Disconnect (or cancel a connect in progress). Completion: on_connection_state(). - virtual int disconnect() = 0; - /// Discover the peer's services/characteristics/descriptors into the - /// service table. Completion: on_service_discovery_done(). - virtual int discover_services() = 0; - virtual int read_characteristic(uint16_t handle) = 0; - virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0; - virtual int read_descriptor(uint16_t handle) = 0; - virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0; - /// Enable/disable delivery of on_notify_data() for a characteristic value - /// handle. Local registration only — the CCCD write is the API client's - /// responsibility (it arrives as a plain write_descriptor). - virtual int notify_characteristic(uint16_t handle, bool enable) = 0; - /// Initiate pairing on the live link. Completion: on_pairing_result(). - virtual int pair() { return GATT_ERR_NOT_CONNECTED; } - virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) = 0; - - /// Backend-owned service table (see GattServiceTable lifetime). - virtual GattServiceTable get_service_table() = 0; - /// Free the transient service table storage. Call after streaming; - /// idempotent (a call with no table held is a no-op). - virtual void release_services() = 0; - - protected: - GattClientEventListener *listener_{nullptr}; +// The event sink the backend calls directly (the hub BluetoothConnection +// wrapper), asserted where the wrapper is defined: on_connection_state +// carries the negotiated MTU and an HCI status/disconnect reason. The +// requirements check call validity, not exact parameter types; keep sink +// parameters at the documented widths (uint16_t handles and lengths). +template +concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { + { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; + { sink.on_service_discovery_done(int{}) } -> std::same_as; + { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; + { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; + { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; + { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { sink.on_pairing_result(int{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index d9a7731504..f4ad051430 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -58,8 +58,9 @@ struct HubCapabilities { /// may only see them where the receiver merges per address (Home Assistant does). bool merges_scan_response; /// GATT client connections are available: the platform has a - /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection - /// (ble_gatt_client.h). Today: esp32 and rp2. + /// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in + /// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client). + /// Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h new file mode 100644 index 0000000000..d8792b88c1 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -0,0 +1,64 @@ +// bluetooth_connection_gatt_backend.h +// +// Binds ble_device_base::BLEGattConnection to the build's one GATT backend. +// Backend and consumer both live in this component, so the ladder does too; +// backends implement ble_gatt_client.h (the neutral contract). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#if defined(USE_RP2040_BLE) +#include "bluetooth_connection_rp2.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) +// Emitted only by the host unit-test manifest: the tests compile the hub +// wrapper standalone, so bind a do-nothing backend. Every other backend-less +// build hits the #error below. +namespace esphome::bluetooth_connection { + +class BluetoothConnection; + +class StubGattBackend { + public: + void set_listener(BluetoothConnection *listener) {} + int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services() {} +}; + +} // namespace esphome::bluetooth_connection +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend +#else +#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here" +#endif + +namespace esphome::ble_device_base { + +using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; +static_assert(BLEGattConnectionContract, + "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); +#undef ESPHOME_BLE_GATT_CONNECTION_TYPE + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index b69a07fc31..ec03f18e1d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -96,7 +96,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- GattClientEventListener ---- +// ---- backend event sink ---- void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { if (connected && this->address_ == 0) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 34e400ac01..e79ee9e7a8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,6 +1,6 @@ -// Hub-platform BluetoothConnection: drives a platform GATT client backend -// through the neutral ble_device_base::BLEGattConnection interface and -// translates its events into the same API messages the esp32 class emits. +// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// ble_device_base::BLEGattConnection alias) and translates its events into +// the same API messages the esp32 class emits. // Presents the identical method surface, so the proxy's GATT dispatch // compiles against either class unchanged. @@ -13,7 +13,7 @@ #include "bluetooth_connection.h" #include "esphome/components/ble_device_base/ble_client_state.h" -#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" namespace esphome::bluetooth_proxy { @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final : public ble_device_base::GattClientEventListener { +class BluetoothConnection final { public: /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { @@ -83,14 +83,14 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->check_disconnect_timeout_(); } - // ---- ble_device_base::GattClientEventListener ---- - void on_connection_state(bool connected, uint16_t mtu, int error) override; - void on_service_discovery_done(int error) override; - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; - void on_write_result(uint16_t handle, int error) override; - void on_notify_state(uint16_t handle, bool enabled, int error) override; - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; - void on_pairing_result(int status) override; + // ---- backend event sink (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error); + void on_service_discovery_done(int error); + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); + void on_write_result(uint16_t handle, int error); + void on_notify_state(uint16_t handle, bool enabled, int error); + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); + void on_pairing_result(int status); protected: friend class bluetooth_proxy::BluetoothProxy; @@ -102,8 +102,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); - // Memory optimized layout for 32-bit systems (a vptr precedes: pointers and - // 2-byte members first fill to an 8-byte boundary before address_) + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; ble_device_base::BLEGattConnection *backend_{nullptr}; @@ -129,6 +128,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene bool services_discovered_{false}; }; +static_assert(ble_device_base::GattClientEventSinkContract, + "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); + } // namespace esphome::bluetooth_connection #endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index cd7577e7f7..dc730659f5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,4 +1,6 @@ #include "bluetooth_connection_rp2.h" + +#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 1a3671354d..d5bf76e6ee 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -1,11 +1,10 @@ // RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. // -// Implements ble_device_base::BLEGattConnection for the hub BluetoothConnection -// wrapper. BTstack packet handlers run in the CYW43 async-context low-priority -// IRQ (or on the main-loop stack during BluetoothLock release), so handlers -// only copy into per-instance lock-free queues/storage; loop() drains them and -// drives the state machine. Every BTstack call issued from the main loop is -// wrapped in BluetoothLock. +// The build's ble_device_base::BLEGattConnection backend (bound by alias in +// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the +// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy +// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call +// issued from the main loop is wrapped in BluetoothLock. #pragma once @@ -27,6 +26,8 @@ namespace esphome::bluetooth_connection { +class BluetoothConnection; + // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -72,29 +73,28 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; // full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; -class RP2GattClient final : public Component, - public ble_device_base::BLEGattConnection, - public Parented { +class RP2GattClient final : public Component, public Parented { public: void setup() override; void loop() override; void dump_config() override; float get_setup_priority() const override; - // ---- ble_device_base::BLEGattConnection ---- - int connect(uint64_t address, uint8_t addr_type) override; - int disconnect() override; - int discover_services() override; - int read_characteristic(uint16_t handle) override; - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override; - int read_descriptor(uint16_t handle) override; - int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; - int notify_characteristic(uint16_t handle, bool enable) override; - int pair() override; - int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) override; - ble_device_base::GattServiceTable get_service_table() override; - void release_services() override; + void set_listener(BluetoothConnection *listener) { this->listener_ = listener; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + ble_device_base::GattServiceTable get_service_table(); + void release_services(); protected: // Link/engine state. Discovery and GATT ops have their own cursors below — @@ -150,6 +150,7 @@ class RP2GattClient final : public Component, } // Group 1: containers / large storage + BluetoothConnection *listener_{nullptr}; ServiceArena *arena_{nullptr}; esphome::LockFreeQueue event_queue_; esphome::EventPool event_pool_; diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index b4491295db..25b6cbf002 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -1,5 +1,8 @@ // The GATT client contract compiles in no real build until a hub backend is // configured; this TU pins it on the host so the header cannot rot unseen. +// The contract is a concept (BLEGattConnection is a per-platform alias), so +// the minimal backend here proves the concept stays satisfiable and routes +// events through the duck-typed sink the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -8,57 +11,57 @@ namespace esphome::ble_device_base::testing { -class RecordingListener : public GattClientEventListener { - public: - void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } - void on_service_discovery_done(int error) override { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override {} - void on_write_result(uint16_t handle, int error) override {} - void on_notify_state(uint16_t handle, bool enabled, int error) override {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override {} +struct RecordingSink { + void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } + void on_service_discovery_done(int error) { this->discovery_error_ = error; } + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + void on_write_result(uint16_t handle, int error) {} + void on_notify_state(uint16_t handle, bool enabled, int error) {} + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + void on_pairing_result(int status) {} bool connected_{false}; int discovery_error_{0}; }; -class MinimalConnection : public BLEGattConnection { +static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); + +class MinimalConnection { public: - int connect(uint64_t address, uint8_t addr_type) override { + void set_listener(RecordingSink *listener) { this->listener_ = listener; } + + int connect(uint64_t address, uint8_t addr_type) { if (this->listener_ != nullptr) this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() override { return 0; } - int discover_services() override { + int disconnect() { return 0; } + int discover_services() { if (this->listener_ != nullptr) this->listener_->on_service_discovery_done(0); return 0; } - int read_characteristic(uint16_t handle) override { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override { return 0; } - int read_descriptor(uint16_t handle) override { return 0; } - int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override { return 0; } - int notify_characteristic(uint16_t handle, bool enable) override { return 0; } - int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) override { + int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int read_descriptor(uint16_t handle) { return 0; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } + int notify_characteristic(uint16_t handle, bool enable) { return 0; } + int pair() { return GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { return 0; } - GattServiceTable get_service_table() override { return {}; } - void release_services() override {} + GattServiceTable get_service_table() { return {}; } + void release_services() {} + + protected: + RecordingSink *listener_{nullptr}; }; -TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) { - // pair() defaults to not-connected and on_pairing_result() to a no-op, so - // a backend without pairing still answers the client through the dispatch. - MinimalConnection conn; - RecordingListener listener; - conn.set_listener(&listener); - EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED); - listener.on_pairing_result(0); // must not crash: default body -} +static_assert(BLEGattConnectionContract, + "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingListener listener; + RecordingSink listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index eae98931ec..eb6e174c0c 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -9,6 +9,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # These defines are global to the merged host test binary; safe # because no co-compiled test observes them. cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") cg.add_define("USE_BLUETOOTH_PROXY") cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) From 8c74e3d5efa3387ca45f0d2ad3b134d9e0017ecc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:49 -0500 Subject: [PATCH 038/597] [bluetooth_proxy] Deliver scanner state through the hub callback (#18175) --- esphome/components/ble_device_base/ble_hub.h | 35 ++++++++++++ .../components/bluetooth_proxy/__init__.py | 8 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 56 ++++++++++--------- .../bluetooth_proxy/bluetooth_proxy.h | 16 +----- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++ .../esp32_ble_tracker/esp32_ble_tracker.h | 14 +---- esphome/core/defines.h | 1 + .../ble_device_base/test_slot_counter.py | 10 ++-- .../test_scanner_state_callback.cpp | 52 +++++++++++++++++ 9 files changed, 136 insertions(+), 61 deletions(-) create mode 100644 tests/components/ble_device_base/test_scanner_state_callback.cpp diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index f4ad051430..aa813d03db 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -49,6 +49,28 @@ struct RawAdvertisementCallback { void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } }; +/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast +/// directly (pinned by static_asserts at the cast sites). +enum class ScannerState : uint8_t { + IDLE = 0, + STARTING = 1, + RUNNING = 2, + FAILED = 3, + STOPPING = 4, + STOPPED = 5, +}; + +/// Subscriber slot for scanner-state transitions; same shape as +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Hubs that +/// cannot push drop the registration and the consumer falls back to polling +/// scan_running(). +struct ScannerStateCallback { + void *instance{nullptr}; + void (*fn)(void *instance, ScannerState state){nullptr}; + bool is_set() const { return this->fn != nullptr; } + void invoke(ScannerState state) const { this->fn(this->instance, state); } +}; + /// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. struct HubCapabilities { /// Controller can send scan requests (active scanning). @@ -79,6 +101,19 @@ class BLEHub { /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + /// Push subscriber for scanner-state transitions; hubs that can push + /// invoke scanner_state_callback_ where their state changes. Compiled only + /// when a subscriber exists (bluetooth_proxy emits the define), so + /// subscriber-less builds carry no storage. + void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; } + + protected: + ScannerStateCallback scanner_state_callback_{}; + + public: +#endif // USE_BLE_SCANNER_STATE_CALLBACK + virtual HubCapabilities get_capabilities() const = 0; /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4aa4195ff9..8d9aa88bd8 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,12 +374,10 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - # Advertisements arrive through the hub raw callback (installed in - # setup()); only the scanner-state listener still registers with the - # tracker directly. + # Advertisements and scanner state arrive through the hub callbacks + # (installed in setup()); the tracker stays typed for scan-mode calls. tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) cg.add(var.set_parent(tracker)) - await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) @@ -428,3 +426,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_BLUETOOTH_PROXY") + # Compiles the scanner-state push slot into the hub (see ble_hub.h). + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 56b79fe1b4..3f44adbef4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -25,15 +25,22 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } -#ifdef USE_ESP32 +// The neutral enum's values are the wire values. +static_assert(static_cast(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE); +static_assert(static_cast(ble_device_base::ScannerState::STARTING) == + api::enums::BLUETOOTH_SCANNER_STATE_STARTING); +static_assert(static_cast(ble_device_base::ScannerState::RUNNING) == + api::enums::BLUETOOTH_SCANNER_STATE_RUNNING); +static_assert(static_cast(ble_device_base::ScannerState::FAILED) == + api::enums::BLUETOOTH_SCANNER_STATE_FAILED); +static_assert(static_cast(ble_device_base::ScannerState::STOPPING) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPING); +static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPED); -void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } -} - -void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { +bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { + if (this->api_connection_ == nullptr) + return false; api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -41,30 +48,21 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + return this->api_connection_->send_message(resp); } -#else // !USE_ESP32 - -void BluetoothProxy::send_bluetooth_scanner_state_() { +#ifndef USE_ESP32 +void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a // full TX buffer) is retried from loop() instead of leaving a stale state. const bool running = this->hub_->scan_running(); - api::BluetoothScannerStateResponse resp; - resp.state = running ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; - resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - resp.configured_mode = this->configured_scan_active_ - ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - if (this->api_connection_->send_message(resp)) { + if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING + : ble_device_base::ScannerState::IDLE)) { this->last_scan_running_ = running; } } - -#endif // USE_ESP32 +#endif // !USE_ESP32 void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -77,6 +75,9 @@ void BluetoothProxy::setup() { this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { static_cast(self)->on_raw_advertisement_(adv); }}); + this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { + static_cast(self)->send_bluetooth_scanner_state_(state); + }}); } // The hub delivers raw advertisements on the ESPHome main loop. @@ -510,9 +511,10 @@ void BluetoothProxy::loop() { return; } - // The hub has no scanner-state listener interface; poll and report on change. + // This hub doesn't push scanner-state transitions; poll and report on + // change. A hub gaining push must also refresh last_scan_running_ here. if (this->hub_->scan_running() != this->last_scan_running_) { - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } this->flush_pending_advertisements_(); @@ -600,7 +602,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { // Reports the mode change; the sender also refreshes last_scan_running_, so // a failed restart (scan_running_ dropped by the tracker) is not reported // again by loop() on the next tick. - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } } @@ -623,7 +625,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection #ifdef USE_ESP32 this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); #else - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); #endif } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index ed39a697aa..86d45c144a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -72,11 +72,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -#ifdef USE_ESP32 -class BluetoothProxy final : public esp32_ble_tracker::BLEScannerStateListener, public Component { -#else class BluetoothProxy final : public Component { -#endif #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; @@ -135,11 +131,6 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } -#ifdef USE_ESP32 - /// BLEScannerStateListener interface - void on_scanner_state(esp32_ble_tracker::ScannerState state) override; -#endif - uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -213,10 +204,9 @@ class BluetoothProxy final : public Component { } protected: -#ifdef USE_ESP32 - void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); -#else - void send_bluetooth_scanner_state_(); + bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); +#ifndef USE_ESP32 + void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index cec2f230f8..e51b293bfe 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -422,6 +422,11 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + if (this->scanner_state_callback_.is_set()) { + this->scanner_state_callback_.invoke(state); + } +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 88642fff6b..c570c28122 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -95,18 +95,8 @@ using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; using ble_device_base::client_state_to_string; -enum class ScannerState { - // Scanner is idle, init state - IDLE, - // Scanner is starting - STARTING, - // Scanner is running - RUNNING, - // Scanner failed to start - FAILED, - // Scanner is stopping - STOPPING, -}; +// Neutral scanner lifecycle re-exported for backward compatibility. +using ScannerState = ble_device_base::ScannerState; /** Listener interface for BLE scanner state changes. * diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1685467a4b..7ddc607c5c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,6 +252,7 @@ // platforms whose API/network types the proxy header cannot assume. #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY +#define USE_BLE_SCANNER_STATE_CALLBACK // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on // advertisement-only hubs) — so static analysis checks the same diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index 1c1499cb2d..daa2884588 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -103,17 +103,17 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None -def test_esp32_bluetooth_proxy_requests_scanner_state_slot( +def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The proxy requests one scanner state slot and a client slot per - connection (three by default with active: true); advertisements arrive - through the hub raw callback, so no listener slot exists.""" + """The proxy requests a client slot per connection (three by default with + active: true); advertisements and scanner state arrive through the hub + callbacks, so no listener or scanner-state slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert ( get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - == "1" + is None ) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" diff --git a/tests/components/ble_device_base/test_scanner_state_callback.cpp b/tests/components/ble_device_base/test_scanner_state_callback.cpp new file mode 100644 index 0000000000..7515b2f38e --- /dev/null +++ b/tests/components/ble_device_base/test_scanner_state_callback.cpp @@ -0,0 +1,52 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback: +// a default-constructed slot is "no subscriber", a set slot delivers the +// state, and a new registration replaces the old. +namespace { + +struct CapturingSubscriber { + ScannerState last{ScannerState::IDLE}; + int calls{0}; + + static void trampoline(void *self, ScannerState state) { + auto *sub = static_cast(self); + sub->last = state; + sub->calls++; + } +}; + +} // namespace + +TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) { + const ScannerStateCallback callback{}; + EXPECT_FALSE(callback.is_set()); +} + +TEST(ScannerStateCallback, SubscriberSeesState) { + CapturingSubscriber subscriber; + ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline}; + ASSERT_TRUE(callback.is_set()); + callback.invoke(ScannerState::RUNNING); + EXPECT_EQ(subscriber.calls, 1); + EXPECT_EQ(subscriber.last, ScannerState::RUNNING); +} + +TEST(ScannerStateCallback, NewSubscriberReplacesOld) { + CapturingSubscriber first; + CapturingSubscriber second; + ScannerStateCallback callback{&first, CapturingSubscriber::trampoline}; + callback = {&second, CapturingSubscriber::trampoline}; + callback.invoke(ScannerState::STOPPED); + EXPECT_EQ(first.calls, 0); + EXPECT_EQ(second.calls, 1); + EXPECT_EQ(second.last, ScannerState::STOPPED); +} + +} // namespace esphome::ble_device_base::testing From c7d6b4aaa4643d97ea21e097872d2f03178adc3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 19:13:07 -0500 Subject: [PATCH 039/597] [esp32_ble_tracker] Retire the raw listener path and parser-type enum (#18177) --- .../bluetooth_connection_esp32.cpp | 6 -- .../bluetooth_connection_esp32.h | 3 +- .../bluetooth_proxy/bluetooth_proxy.h | 23 +------- esphome/components/esp32_ble/ble.cpp | 16 +++++- esphome/components/esp32_ble/ble.h | 2 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 57 ++----------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 17 ++---- 7 files changed, 30 insertions(+), 94 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index be6fa4c6c5..f5c59ca43a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -479,12 +479,6 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } -esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - // RAW keeps the tracker from building parsed ESPBTDevice objects for the - // proxy's connections (the proxy itself consumes the hub raw callback). - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; -} - } // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index 531ff311a7..fb60d93e9c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -21,7 +21,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; + // The proxy's connections never consume parsed ESPBTDevice objects. + bool wants_parsed_advertisements() override { return false; } esp_err_t read_characteristic(uint16_t handle); esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 86d45c144a..9fc975680e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -20,10 +20,6 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID -#include -#endif -#include #else #include "esphome/components/ble_device_base/ble_hub.h" #ifdef USE_BLE_GATT_CLIENT @@ -179,28 +175,15 @@ class BluetoothProxy final : public Component { } void get_bluetooth_mac_address_pretty(std::span output) { -#ifdef USE_ESP32 - const uint8_t *mac = esp_bt_dev_get_address(); - if (mac != nullptr) { - format_mac_addr_upper(mac, output.data()); - } else { - output[0] = '\0'; - } -#else uint8_t mac[6] = {}; this->hub_->get_adapter_mac(mac); - // Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs - // (rp2040's BTstack) only learn the address once the link layer is up, and - // report all-zero until then. - bool nonzero = false; - for (uint8_t b : mac) - nonzero |= b != 0; - if (nonzero) { + // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn + // the address once the link layer is up, and report all-zero until then. + if (mac_address_is_valid(mac)) { format_mac_addr_upper(mac, output.data()); } else { output[0] = '\0'; } -#endif } protected: diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fb75e8837f..d11683ab35 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,11 +674,23 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif +void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { + // The running stack owns the address (on hosted controllers it lives in + // the remote chip's efuse); null before init becomes all-zero. + const uint8_t *mac = esp_bt_dev_get_address(); + if (mac != nullptr) { + memcpy(out, mac, 6); + } else { + memset(out, 0, 6); + } +} + float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - const uint8_t *mac_address = esp_bt_dev_get_address(); - if (mac_address) { + uint8_t mac_address[6]; + this->get_mac_msb_first(mac_address); + if (mac_address_is_valid(mac_address)) { const char *io_capability_s; switch (this->io_cap_) { case ESP_IO_CAP_OUT: diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index c85ddfc983..45cfd8ee71 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -108,6 +108,8 @@ class ESP32BLE final : public Component { void setup() override; void loop() override; void dump_config() override; + /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. + void get_mac_msb_first(uint8_t out[6]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e51b293bfe..0950bfeb70 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -271,7 +271,9 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { // Safe because ESP32BLETracker (singleton) outlives all registered clients. client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); - this->recalculate_advertisement_parser_types(); + // Registration is add-only, so the flag is a monotonic OR. + if (client->wants_parsed_advertisements()) + this->parse_advertisements_ = true; #endif } @@ -283,48 +285,11 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li #endif } -void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { - get_mac_address_raw(out); // WiFi base MAC, MSB-first - // BT MAC = base MAC + 2 on the last octet only, wrapping without carry — - // exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET. - out[5] += 2; -} - void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); this->listeners_.push_back(listener); - this->recalculate_advertisement_parser_types(); -#endif -} - -void ESP32BLETracker::recalculate_advertisement_parser_types() { - this->raw_advertisements_ = false; - this->parse_advertisements_ = false; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in - // listeners_; without this, any later esp32-path registration (e.g. the proxy's - // GATT clients) would recompute the flags and silently drop parsed dispatch. - if (!this->neutral_listeners_.empty()) - this->parse_advertisements_ = true; -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } + this->parse_advertisements_ = true; #endif } @@ -478,20 +443,6 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { this->raw_advertisement_callback_.invoke(adv); } - // Process raw advertisements - if (this->raw_advertisements_) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - listener->parse_devices(&scan_result, 1); - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->parse_devices(&scan_result, 1); - } -#endif - } - // Process parsed advertisements if (this->parse_advertisements_) { #ifdef USE_ESP32_BLE_DEVICE diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c570c28122..ee1b1429c0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -35,11 +35,6 @@ using namespace esp32_ble; using adv_data_t = ble_device_base::adv_data_t; -enum AdvertisementParserType { - PARSED_ADVERTISEMENTS, - RAW_ADVERTISEMENTS, -}; - #ifdef USE_ESP32_BLE_UUID using ServiceData = ble_device_base::ServiceData; #endif @@ -63,10 +58,6 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { // Raw-only build: no parsed-device support is compiled in. bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif - virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; - virtual AdvertisementParserType get_advertisement_parser_type() { - return AdvertisementParserType::PARSED_ADVERTISEMENTS; - }; void set_parent(ESP32BLETracker *parent) { parent_ = parent; } protected: @@ -123,6 +114,10 @@ class BLEScannerStateListener { /// The pointer may be null if the client is not registered with a tracker. class ESPBTClient : public ESPBTDeviceListener { public: + /// False keeps the tracker from building parsed ESPBTDevice objects on + /// this client's account (raw consumers use the hub callback). + virtual bool wants_parsed_advertisements() { return true; } + virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; @@ -199,7 +194,6 @@ class ESP32BLETracker final : public Component, // esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer). void register_listener(ESPBTDeviceListener *listener); void register_client(ESPBTClient *client); - void recalculate_advertisement_parser_types(); // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; @@ -212,7 +206,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) override; + void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_active() override { return this->scan_active_; } @@ -355,7 +349,6 @@ class ESP32BLETracker final : public Component, bool scan_continuous_before_ota_{false}; #endif bool ble_was_disabled_{true}; - bool raw_advertisements_{false}; bool parse_advertisements_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; From 2bb01853e0917505f5dcea44ca7e031dfe946010 Mon Sep 17 00:00:00 2001 From: Edu_Coder Date: Sat, 8 Aug 2026 20:53:10 -0400 Subject: [PATCH 040/597] [tuya] GMT time 0x0C command handler (#17158) --- esphome/components/tuya/tuya.cpp | 35 ++++++++++++++++++++++++++++++++ esphome/components/tuya/tuya.h | 3 +++ 2 files changed, 38 insertions(+) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 3058d82cc4..15ab4b6dc3 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -303,6 +303,22 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff ESP_LOGW(TAG, "LOCAL_TIME_QUERY is not handled because time is not configured"); } break; + case TuyaCommandType::GMT_TIME_QUERY: +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + this->send_gmt_time_(); + + if (!this->gmt_time_sync_callback_registered_) { + // tuya mcu supports time, so we let them know when our time changed + this->time_id_->add_on_time_sync_callback([this] { this->send_gmt_time_(); }); + this->gmt_time_sync_callback_registered_ = true; + } + } else +#endif + { + ESP_LOGW(TAG, "GMT_TIME_QUERY is not handled because time is not configured"); + } + break; case TuyaCommandType::VACUUM_MAP_UPLOAD: this->send_command_( TuyaCommand{.cmd = TuyaCommandType::VACUUM_MAP_UPLOAD, .payload = std::vector{0x01}}); @@ -609,6 +625,25 @@ void Tuya::send_local_time_() { } this->send_command_(TuyaCommand{.cmd = TuyaCommandType::LOCAL_TIME_QUERY, .payload = payload}); } +void Tuya::send_gmt_time_() { + std::vector payload; + ESPTime now = this->time_id_->utcnow(); + if (now.is_valid()) { + uint8_t year = now.year - 2000; + uint8_t month = now.month; + uint8_t day_of_month = now.day_of_month; + uint8_t hour = now.hour; + uint8_t minute = now.minute; + uint8_t second = now.second; + ESP_LOGD(TAG, "Sending gmt time"); + payload = std::vector{0x01, year, month, day_of_month, hour, minute, second}; + } else { + // By spec we need to notify MCU that the time was not obtained if this is a response to a query + ESP_LOGW(TAG, "Sending missing gmt time"); + payload = std::vector{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + } + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::GMT_TIME_QUERY, .payload = payload}); +} #endif void Tuya::set_raw_datapoint_value(uint8_t datapoint_id, const std::vector &value) { diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 4e7ab5c7f9..b8bf4e0ab1 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -54,6 +54,7 @@ enum class TuyaCommandType : uint8_t { DATAPOINT_DELIVER = 0x06, DATAPOINT_REPORT_ASYNC = 0x07, DATAPOINT_QUERY = 0x08, + GMT_TIME_QUERY = 0x0C, WIFI_TEST = 0x0E, LOCAL_TIME_QUERY = 0x1C, DATAPOINT_REPORT_SYNC = 0x22, @@ -138,8 +139,10 @@ class Tuya final : public Component, public uart::UARTDevice { #ifdef USE_TIME void send_local_time_(); + void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; bool time_sync_callback_registered_{false}; + bool gmt_time_sync_callback_registered_{false}; #endif TuyaInitState init_state_ = TuyaInitState::INIT_HEARTBEAT; bool init_failed_{false}; From 862b13c8ddf4fbe0289f562472c3602edec8d502 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 20:58:48 -0500 Subject: [PATCH 041/597] [esp32_ble_tracker] Retire the scanner-state listener interface (#18179) --- .../components/esp32_ble_tracker/__init__.py | 19 +-------------- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ---- .../esp32_ble_tracker/esp32_ble_tracker.h | 23 ------------------- esphome/core/defines.h | 1 - .../ble_device_base/test_slot_counter.py | 12 ++-------- 5 files changed, 3 insertions(+), 57 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 646ce79233..b1ad07dfdd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -66,12 +66,9 @@ def _get_required_features() -> set[BLEFeatures]: # Slot counters sizing the tracker's StaticVector storage; one request per -# registered listener, client, or scanner state listener. +# registered listener or client. _request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") _request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") -_request_scanner_state_listener_slot = cg.slot_counter( - "ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT" -) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -386,17 +383,3 @@ async def register_raw_client( paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var - - -async def register_scanner_state_listener( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a listener for scanner state changes. - - The slot request here is what sizes the tracker's listener storage; a - build with no registrations compiles the storage out entirely. - """ - _request_scanner_state_listener_slot() - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.add_scanner_state_listener(var)) - return var diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0950bfeb70..18b6cf022d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -392,11 +392,6 @@ void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_callback_.invoke(state); } #endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - for (auto *listener : this->scanner_state_listeners_) { - listener->on_scanner_state(state); - } -#endif } void ESP32BLETracker::dump_config() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index ee1b1429c0..9031d86c97 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -89,16 +89,6 @@ using ble_device_base::client_state_to_string; // Neutral scanner lifecycle re-exported for backward compatibility. using ScannerState = ble_device_base::ScannerState; -/** Listener interface for BLE scanner state changes. - * - * Components can implement this interface to receive scanner state updates - * without the overhead of std::function callbacks. - */ -class BLEScannerStateListener { - public: - virtual void on_scanner_state(ScannerState state) = 0; -}; - /// Base class for BLE GATT clients that connect to remote devices. /// /// State Change Tracking Design: @@ -226,15 +216,6 @@ class ESP32BLETracker final : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - /// Add a listener for scanner state changes. Only compiled when a consumer - /// requested a slot in codegen: register through - /// esp32_ble_tracker.register_scanner_state_listener() in your component's - /// to_code, which requests the slot and emits this call. - void add_scanner_state_listener(BLEScannerStateListener *listener) { - this->scanner_state_listeners_.push_back(listener); - } -#endif ScannerState get_scanner_state() const { return this->scanner_state_; } protected: @@ -300,10 +281,6 @@ class ESP32BLETracker final : public Component, #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - StaticVector - scanner_state_listeners_; #endif // Parsed listeners registered through the neutral BLEHub contract (migrated // sensors); dispatched alongside listeners_. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7ddc607c5c..fd351356df 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -307,7 +307,6 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 -#define ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index daa2884588..e784c9871e 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -94,11 +94,7 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None - # No consumer subscribed to scanner state, so the storage compiles out. - assert ( - get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - is None - ) + # No advertisement listener or client is registered, so both storages compile out. assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None @@ -109,12 +105,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( ) -> None: """The proxy requests a client slot per connection (three by default with active: true); advertisements and scanner state arrive through the hub - callbacks, so no listener or scanner-state slot exists.""" + callbacks, so no listener slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) - assert ( - get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - is None - ) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" From e9f428983ea3b8faa31c400cfc453ee17883b747 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 08:57:31 -0500 Subject: [PATCH 042/597] [ble_device_base] Bind BLEHub to the build's tracker at compile time (#18181) --- .../components/bk72xx_ble_tracker/__init__.py | 3 + .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 15 ++-- .../components/ble_device_base/__init__.py | 28 +++--- .../components/ble_device_base/automation.h | 11 +-- esphome/components/ble_device_base/ble_hub.h | 88 ++++++++----------- .../components/ble_device_base/ble_hub_impl.h | 35 ++++++++ .../components/bluetooth_proxy/__init__.py | 10 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 31 ++++--- .../bluetooth_proxy/bluetooth_proxy.h | 30 ++----- .../components/esp32_ble_tracker/__init__.py | 3 + .../esp32_ble_tracker/esp32_ble_tracker.h | 24 +++-- .../components/ln882h_ble_tracker/__init__.py | 3 + .../ln882h_ble_tracker/ln882h_ble_tracker.h | 15 ++-- .../components/rp2_ble_tracker/__init__.py | 3 + .../rp2_ble_tracker/rp2_ble_tracker.h | 15 ++-- esphome/core/defines.h | 18 +++- .../config/ln882h_tracker.yaml | 7 ++ .../ble_device_base/test_hub_binding.py | 29 +++++- .../ble_device_base/test_raw_callback.cpp | 11 +-- .../test_scan_mode_request.cpp | 76 ---------------- 20 files changed, 228 insertions(+), 227 deletions(-) create mode 100644 esphome/components/ble_device_base/ble_hub_impl.h create mode 100644 tests/component_tests/ble_device_base/config/ln882h_tracker.yaml delete mode 100644 tests/components/ble_device_base/test_scan_mode_request.cpp diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index e7f8ed92ba..7fefb310cd 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -144,6 +144,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_BK72XX_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 67e4467c77..cc51918da5 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -45,7 +45,6 @@ namespace esphome::bk72xx_ble_tracker { // --------------------------------------------------------------------------- class BK72xxBLETracker : public Component, - public ble_device_base::BLEHub, public bk72xx_ble::BLEScanListener, public Parented #ifdef USE_OTA_STATE_LISTENER @@ -93,15 +92,15 @@ class BK72xxBLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // The Beken BDK exposes no active-scan path (passive scanning only), so the // controller never solicits scan responses and never merges them; consumers // relying on scan-response fields (device names) get them only where the @@ -110,21 +109,21 @@ class BK72xxBLETracker : public Component, // path there is no mode to switch to. return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; } - bool request_scan_mode(bool active) override { + bool request_scan_mode(bool active) { // Passive-only controller: a passive request is already honored, an active // one cannot be. return !active; } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) override { + void get_adapter_mac(uint8_t out[6]) { uint8_t mac[6]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return false; } // BK72xx scan is passive-only + bool scan_running() { return this->scan_running_; } + bool scan_active() { return false; } // BK72xx scan is passive-only // ---- bk72xx_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index a52286f456..fa66448867 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -3,19 +3,22 @@ ble_device_base — the platform-neutral BLE layer. Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract -(BLEHub, in ble_hub.h) on every platform. +(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h) +on every platform. BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared -subclass, so there is no platform table here and no dependency in either -direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an -explicit ble_hub_id: is a declared key even on strict schemas) and calls -register_ble_device() in to_code; a tracker component subclasses BLEHub (C++ -and codegen class) and MUST call register_hub_provider() at import time — -without it _require_hub rejects configs that bind through the generated id -(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip -requires only a new in-tree tracker component; out-of-tree BLE hubs are -not supported. +subclass, so there is no Python platform table here and no dependency in +either direction (C++-side, the compile-time alias header ble_hub_impl.h and the +defines.h mirror are the deliberate exceptions). A sensor extends +BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a +declared key even on strict schemas) and calls register_ble_device() in +to_code; a tracker component declares BLEHub as its codegen-class parent and +MUST call register_hub_provider() at import time — without it _require_hub +rejects configs that bind through the generated id (an explicit ble_hub_id: +bypasses the registry). Adding a new BLE chip requires a new in-tree tracker +component plus its alias arm and define (see above); out-of-tree BLE hubs +are not supported. AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. @@ -48,8 +51,9 @@ LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") -# The neutral tracker contract. Every tracker's codegen class declares this as a -# parent, which is what lets cv.use_id(BLEHub) resolve any of them. +# The neutral tracker contract. Every tracker's codegen class declares this as +# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python +# only: C++-side the name is a per-platform alias (ble_hub_impl.h). BLEHub = ble_device_base_ns.class_("BLEHub") # The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h index 507b3278d9..ba3128c0ee 100644 --- a/esphome/components/ble_device_base/automation.h +++ b/esphome/components/ble_device_base/automation.h @@ -1,11 +1,12 @@ // Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses // registered on a BLEHub, exposed by each tracker under its own automation // names. parse_device()'s return feeds the "Found device" suppression. +// Constructors are templated on the hub type so this header also builds with +// no tracker present (host unit tests). #pragma once #include "ble_device.h" -#include "ble_hub.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" @@ -18,7 +19,7 @@ namespace esphome::ble_device_base { // on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } @@ -39,7 +40,7 @@ class ESPBTAdvertiseTrigger final : public Trigger, public // data for the given UUID. Optional single-MAC filter. class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } @@ -73,7 +74,7 @@ class BLEServiceDataAdvertiseTrigger final : public Trigger, // manufacturer data for the given ID. Optional single-MAC filter. class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } @@ -108,7 +109,7 @@ class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); } bool parse_device(const ESPBTDevice &device) override { return false; } void on_scan_end() override { this->trigger(); } diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index aa813d03db..8e4c710bb1 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -1,13 +1,10 @@ // ble_hub.h // -// BLEHub — the platform-neutral BLE tracker contract. -// -// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, -// ln882h_ble_tracker, future chips) implements this interface; every BLE -// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via -// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares. -// Adding a new BLE chip therefore requires only a new tracker component that -// implements BLEHub: no consumer, registry, or base changes. +// The platform-neutral BLE tracker contract: shared types plus the method +// surface every tracker provides (documented below). Exactly one tracker +// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not +// an abstract interface — no vtable, every hub call inlinable. Consumers +// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub). // // Chip differences are expressed as data (HubCapabilities), never as // platform conditionals in consumers. @@ -15,7 +12,9 @@ #pragma once #include "ble_device.h" +#include "esphome/core/defines.h" +#include #include namespace esphome::ble_device_base { @@ -61,9 +60,8 @@ enum class ScannerState : uint8_t { }; /// Subscriber slot for scanner-state transitions; same shape as -/// RawAdvertisementCallback, delivered on the ESPHome main loop. Hubs that -/// cannot push drop the registration and the consumer falls back to polling -/// scan_running(). +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs +/// that push provide the setter; consumers of the rest poll scan_running(). struct ScannerStateCallback { void *instance{nullptr}; void (*fn)(void *instance, ScannerState state){nullptr}; @@ -91,48 +89,34 @@ struct HubCapabilities { bool scan_mode_switch; }; -class BLEHub { - public: - virtual ~BLEHub() = default; - - /// Register a parsed-advertisement consumer (BLE sensors, automation triggers). - virtual void register_listener(ESPBTDeviceListener *listener) = 0; - - /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. - virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; - +// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias. +// Semantics beyond the signatures: +// - register_listener: parsed-advertisement consumers (sensors, triggers). +// - set_raw_advertisement_callback: raw stream, one consumer at a time. +// - get_adapter_mac: printable order, out[0] = MSB. +// - scan_active: the current/configured mode sends scan requests. +// - request_scan_mode: false = cannot honor, state untouched (the caller +// reports the real state back); true = applied immediately, restarting a +// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch. +// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback) +// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept +// requires both exactly when that define is set. A push hub must emit a +// transition for every accepted or refused mode request - consumers skip +// their own mode report on push builds. +template +concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback, + uint8_t *mac) { + hub.register_listener(listener); + hub.set_raw_advertisement_callback(raw_callback); + { T::get_capabilities() } -> std::same_as; + hub.get_adapter_mac(mac); + { hub.scan_running() } -> std::same_as; + { hub.scan_active() } -> std::same_as; + { hub.request_scan_mode(true) } -> std::same_as; #ifdef USE_BLE_SCANNER_STATE_CALLBACK - /// Push subscriber for scanner-state transitions; hubs that can push - /// invoke scanner_state_callback_ where their state changes. Compiled only - /// when a subscriber exists (bluetooth_proxy emits the define), so - /// subscriber-less builds carry no storage. - void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; } - - protected: - ScannerStateCallback scanner_state_callback_{}; - - public: -#endif // USE_BLE_SCANNER_STATE_CALLBACK - - virtual HubCapabilities get_capabilities() const = 0; - - /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. - virtual void get_adapter_mac(uint8_t out[6]) = 0; - - virtual bool scan_running() = 0; - /// True when the current/configured scan mode is active (scan requests sent). - virtual bool scan_active() = 0; - /// Request a scan-mode change (active = send scan requests). Returns false - /// when the hub cannot honor the request; the caller reports the real state - /// back to its subscriber. A hub that returns true applies the mode - /// immediately: a running scan is restarted with the new mode, an idle one - /// picks it up on its next start. The default cannot-change keeps hubs - /// without a mode switch (and out-of-tree trackers) building unchanged. - /// Independent of HubCapabilities::active_scan: that bit describes what the - /// CONTROLLER can do; whether this method honors requests is advertised by - /// HubCapabilities::scan_mode_switch, so consumers can gate features on the - /// switch without probing. - virtual bool request_scan_mode(bool active) { return false; } + hub.set_scanner_state_callback(ScannerStateCallback{}); + { hub.get_scanner_state() } -> std::same_as; +#endif }; } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub_impl.h b/esphome/components/ble_device_base/ble_hub_impl.h new file mode 100644 index 0000000000..87214ca7f7 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub_impl.h @@ -0,0 +1,35 @@ +// ble_hub_impl.h +// +// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's +// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header, +// trackers include ble_hub.h (the contract). + +#pragma once + +#include "ble_hub.h" +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE_TRACKER) +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker +#elif defined(USE_RP2_BLE_TRACKER) +#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker +#elif defined(USE_BK72XX_BLE_TRACKER) +#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker +#elif defined(USE_LN882H_BLE_TRACKER) +#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker +#endif +// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub. + +namespace esphome::ble_device_base { + +#ifdef ESPHOME_BLE_HUB_TYPE +using BLEHub = ESPHOME_BLE_HUB_TYPE; +static_assert(BLEHubContract, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)"); +#undef ESPHOME_BLE_HUB_TYPE +#endif + +} // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 8d9aa88bd8..a28c8abc71 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,10 +374,12 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - # Advertisements and scanner state arrive through the hub callbacks - # (installed in setup()); the tracker stays typed for scan-mode calls. tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) - cg.add(var.set_parent(tracker)) + cg.add(var.set_ble_hub(tracker)) + + # Compiles the scanner-state push slot into the tracker and the matching + # registration into the proxy; the other hubs are polled instead. + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) @@ -426,5 +428,3 @@ async def to_code(config: ConfigType) -> None: cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_BLUETOOTH_PROXY") - # Compiles the scanner-state push slot into the hub (see ble_hub.h). - cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 3f44adbef4..88d8cc1885 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -51,7 +51,7 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -62,7 +62,7 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_ESP32 +#endif // !USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -75,9 +75,12 @@ void BluetoothProxy::setup() { this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { static_cast(self)->on_raw_advertisement_(adv); }}); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { static_cast(self)->send_bluetooth_scanner_state_(state); }}); +#endif } // The hub delivers raw advertisements on the ESPHome main loop. @@ -469,13 +472,15 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #ifdef USE_ESP32 void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_()->get_scan_active() == active) { + // esp32 only: BLEHub is the concrete tracker here, so these calls reach + // tracker-native methods beyond the neutral contract. + if (this->hub_->get_scan_active() == active) { return; } ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_()->set_scan_active(active); - this->parent_()->stop_scan(); - this->parent_()->set_scan_continuous( + this->hub_->set_scan_active(active); + this->hub_->stop_scan(); + this->hub_->set_scan_continuous( true); // Set this to true to automatically start scanning again when it has cleaned up. } @@ -511,11 +516,13 @@ void BluetoothProxy::loop() { return; } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK // This hub doesn't push scanner-state transitions; poll and report on - // change. A hub gaining push must also refresh last_scan_running_ here. + // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { this->send_polled_scanner_state_(); } +#endif this->flush_pending_advertisements_(); } @@ -598,12 +605,15 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); } } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK if (this->api_connection_ != nullptr) { // Reports the mode change; the sender also refreshes last_scan_running_, so // a failed restart (scan_running_ dropped by the tracker) is not reported - // again by loop() on the next tick. + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. this->send_polled_scanner_state_(); } +#endif } #endif // USE_ESP32 @@ -622,8 +632,9 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; -#ifdef USE_ESP32 - this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // get_scanner_state() is part of the push-hub surface (see BLEHubContract). + this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); #else this->send_polled_scanner_state_(); #endif diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 9fc975680e..d7150617d3 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -15,17 +15,13 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection.h" +#include "esphome/components/ble_device_base/ble_hub_impl.h" + #ifdef USE_ESP32 -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" - -#else -#include "esphome/components/ble_device_base/ble_hub.h" -#ifdef USE_BLE_GATT_CLIENT +#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" #endif -#endif // USE_ESP32 namespace esphome::bluetooth_proxy { @@ -75,11 +71,7 @@ class BluetoothProxy final : public Component { #endif public: BluetoothProxy(); -#ifdef USE_ESP32 - // Advertisements arrive through the hub's raw callback; parent_() below - // recovers the tracker type for the esp32-only scan-mode calls. - void set_parent(esp32_ble_tracker::ESP32BLETracker *parent) { this->hub_ = parent; } -#endif // USE_ESP32 + void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } void dump_config() override; void setup() override; void loop() override; @@ -88,7 +80,6 @@ class BluetoothProxy final : public Component { void register_connection(BluetoothConnection *connection); #endif // BLUETOOTH_CONNECTION_HAS_GATT #ifndef USE_ESP32 - void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and // the BLEHub contract does not promise those are settled any earlier than @@ -152,7 +143,7 @@ class BluetoothProxy final : public Component { // scan_mode_switch is the capability bit for exactly that (#18079) — // active_scan alone is not enough, a hub may support active scanning yet // refuse the runtime switch. - if (this->hub_->get_capabilities().scan_mode_switch) { + if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) { flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; } #endif @@ -188,7 +179,7 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -258,13 +249,6 @@ class BluetoothProxy final : public Component { std::array connections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; -#ifdef USE_ESP32 - // set_parent() is the only writer of hub_ on esp32, so the downcast is - // exact; ESP32BLETracker derives from BLEHub non-virtually. - esp32_ble_tracker::ESP32BLETracker *parent_() { - return static_cast(this->hub_); - } -#endif // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; @@ -279,7 +263,7 @@ class BluetoothProxy final : public Component { bool active_; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b1ad07dfdd..84f43fb54b 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -205,6 +205,9 @@ async def to_code(config): # available on esp32 (sensors with irk: worked without opting in). ble_device_base.request_irk_support() + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_ESP32_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 9031d86c97..30b85b5417 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -161,7 +161,6 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker final : public Component, - public ble_device_base::BLEHub, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -186,19 +185,27 @@ class ESP32BLETracker final : public Component, void register_client(ESPBTClient *client); // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) { + this->scanner_state_callback_ = callback; + } +#endif + static constexpr ble_device_base::HubCapabilities get_capabilities() { // scan_mode_switch is false: the mode is driven through this tracker's own // API (set_scan_active + restart), not the neutral request_scan_mode(). return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } - bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } - bool scan_active() override { return this->scan_active_; } + void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } + bool scan_active() { return this->scan_active_; } + // The mode is driven through this tracker's own API (see get_capabilities); + // the neutral request refuses without changing any state. + bool request_scan_mode(bool active) { return false; } #ifdef USE_ESP32_BLE_DEVICE void print_bt_device_info(const ESPBTDevice &device); @@ -288,6 +295,9 @@ class ESP32BLETracker final : public Component, StaticVector neutral_listeners_; #endif ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + ble_device_base::ScannerStateCallback scanner_state_callback_{}; +#endif #ifdef USE_ESP32_BLE_DEVICE /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) ble_device_base::DiscoveredDeviceLog discovered_log_; diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index ceb2aeffec..45f1b95164 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -127,6 +127,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_LN882H_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 1ad36c40d4..9c0e0b2f1a 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -26,7 +26,6 @@ namespace esphome::ln882h_ble_tracker { // --------------------------------------------------------------------------- class LN882HBLETracker : public Component, - public ble_device_base::BLEHub, public Parented, public ln882h_ble::BLEScanListener #ifdef USE_OTA_STATE_LISTENER @@ -75,15 +74,15 @@ class LN882HBLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive // as separate reports and are merged by this tracker (Bluedroid semantics). // The SDK's GATT client is not exposed. @@ -92,15 +91,15 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) override { + void get_adapter_mac(uint8_t out[6]) { uint8_t mac[6]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return this->scan_active_; } - bool request_scan_mode(bool active) override; + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); // ---- ln882h_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 5840185768..15c1229a85 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -55,6 +55,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_RP2_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 8106763489..054f6a65d2 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -17,7 +17,6 @@ namespace esphome::rp2_ble_tracker { class RP2BLETracker : public Component, - public ble_device_base::BLEHub, public rp2040_ble::BLEScanListener, public Parented #ifdef USE_OTA_STATE_LISTENER @@ -51,15 +50,15 @@ class RP2BLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // BTstack delivers scan responses as separate advertisement reports rather // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver @@ -74,10 +73,10 @@ class RP2BLETracker : public Component, } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. - void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return this->scan_active_; } - bool request_scan_mode(bool active) override; + void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); // ---- rp2040_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main loop — the diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fd351356df..ad24d27369 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,12 +252,12 @@ // platforms whose API/network types the proxy header cannot assume. #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY -#define USE_BLE_SCANNER_STATE_CALLBACK // Mirror the codegen values per platform: _to_code_esp32() emits the connection -// count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on -// advertisement-only hubs) — so static analysis checks the same -// std::array instantiation a real build produces. +// count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits +// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis +// checks the same instantiations a real build produces. #ifdef USE_ESP32 +#define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #elif defined(USE_RP2) #define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 @@ -305,6 +305,7 @@ #define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT +#define USE_ESP32_BLE_TRACKER #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 @@ -465,6 +466,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_BLE_GATT_CLIENT @@ -489,6 +491,14 @@ #define BK72XX_BLE_SCAN_LISTENER_COUNT 1 #define USE_LN882H_BLE #define LN882H_BLE_SCAN_LISTENER_COUNT 1 +// One tracker arm per build: ln882x gets its real hub; bk72xx also stands in +// for hub-less LibreTiny chips (rtl87xx) so bluetooth_proxy.h has a BLEHub +// to parse against. +#ifdef USE_LN882X +#define USE_LN882H_BLE_TRACKER +#else +#define USE_BK72XX_BLE_TRACKER +#endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK diff --git a/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml new file mode 100644 index 0000000000..891d65ecf6 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-ln882h-tracker + +ln882x: + board: generic-ln882h + +ln882h_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_hub_binding.py b/tests/component_tests/ble_device_base/test_hub_binding.py index 3dc71daf58..59aecc461b 100644 --- a/tests/component_tests/ble_device_base/test_hub_binding.py +++ b/tests/component_tests/ble_device_base/test_hub_binding.py @@ -1,6 +1,6 @@ """Tests for the BLE hub provider registry and the missing-hub diagnostics.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from importlib import import_module from pathlib import Path @@ -202,3 +202,30 @@ def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) - assert "0x00,0xff,0xee,0xdd" in emitted[2] with pytest.raises(ValueError, match="Unsupported UUID format"): ble_device_base.add_service_uuid(var, "123") + + +@pytest.mark.parametrize( + ("config_name", "define"), + [ + ("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"), + ("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"), + ("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"), + ("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"), + ], +) +def test_every_tracker_emits_its_alias_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_name: str, + define: str, +) -> None: + """Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the + ble_hub_impl.h alias ladder selects on it. Checked through real codegen + (the other two legs of the invariant, the ladder arm and the defines.h + mirror, are compile-enforced: a missing arm fails any build containing a + BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses + on every tracker platform - and clang-tidy compiles each arm's + static_assert).""" + generate_main(component_config_path(config_name)) + + assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen" diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp index 4d72c8fb18..62a9aebb81 100644 --- a/tests/components/ble_device_base/test_raw_callback.cpp +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing { // // The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against // the Beken SDK and cannot run host-side, so the guard-and-fire semantics are -// pinned here through a minimal host BLEHub implementation instead. +// pinned here through a minimal host hub carrying only the slot under test. namespace { -class FakeHub : public BLEHub { +class FakeHub { public: - void register_listener(ESPBTDeviceListener *listener) override {} - void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; } - HubCapabilities get_capabilities() const override { return {false, false, false}; } - void get_adapter_mac(uint8_t out[6]) override {} - bool scan_running() override { return false; } - bool scan_active() override { return false; } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; } /// The emit path every tracker implements: fire only when a subscriber is set. void emit(const RawAdvertisement &adv) { diff --git a/tests/components/ble_device_base/test_scan_mode_request.cpp b/tests/components/ble_device_base/test_scan_mode_request.cpp deleted file mode 100644 index 9125eb6f2f..0000000000 --- a/tests/components/ble_device_base/test_scan_mode_request.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include - -#include - -#include "esphome/components/ble_device_base/ble_hub.h" - -namespace esphome::ble_device_base::testing { - -// Pins the request_scan_mode() contract: the base default refuses (so hubs -// without a mode switch — and out-of-tree trackers — keep building and -// callers report the real state), while an overriding hub both honors the -// request and applies it. -namespace { - -class DefaultHub : public BLEHub { - public: - void register_listener(ESPBTDeviceListener *listener) override {} - void set_raw_advertisement_callback(RawAdvertisementCallback callback) override {} - HubCapabilities get_capabilities() const override { return {false, false, false}; } - void get_adapter_mac(uint8_t out[6]) override {} - bool scan_running() override { return false; } - // Backed by real state so "changes nothing" is observable: a base default - // that silently mutated the hub would flip this and fail the assertion. - bool scan_active() override { return this->active_; } - - protected: - bool active_{true}; -}; - -class SwitchingHub : public DefaultHub { - public: - HubCapabilities get_capabilities() const override { return {true, false, false, /* scan_mode_switch = */ true}; } - bool request_scan_mode(bool active) override { - this->active_ = active; - return true; - } -}; - -// The esp32 shape: the controller supports active scanning but the hub keeps -// the refusing default (mode is driven through its own tracker API). -class CapableRefusingHub : public DefaultHub { - public: - HubCapabilities get_capabilities() const override { return {true, false, false}; } -}; - -} // namespace - -TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) { - DefaultHub hub; - EXPECT_TRUE(hub.scan_active()); - EXPECT_FALSE(hub.request_scan_mode(false)); - // Refused, not applied-and-reported-false: the state is untouched. - EXPECT_TRUE(hub.scan_active()); - EXPECT_FALSE(hub.request_scan_mode(true)); - EXPECT_TRUE(hub.scan_active()); -} - -TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { - SwitchingHub hub; - EXPECT_TRUE(hub.get_capabilities().scan_mode_switch); - EXPECT_TRUE(hub.request_scan_mode(true)); - EXPECT_TRUE(hub.scan_active()); - EXPECT_TRUE(hub.request_scan_mode(false)); - EXPECT_FALSE(hub.scan_active()); -} - -TEST(BLEHubScanModeRequest, CapabilityAndSwitchAreIndependent) { - CapableRefusingHub hub; - EXPECT_TRUE(hub.get_capabilities().active_scan); - // The esp32 shape advertises no runtime switch, and the request refuses. - EXPECT_FALSE(hub.get_capabilities().scan_mode_switch); - EXPECT_FALSE(hub.request_scan_mode(false)); - EXPECT_TRUE(hub.scan_active()); -} - -} // namespace esphome::ble_device_base::testing From ab12e5490f680ab337236fd149ab59e96e1b373b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 9 Aug 2026 15:00:44 -0700 Subject: [PATCH 043/597] [modbus] Rename send_pdu() to queue_pdu() (#18196) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 14 +- esphome/components/modbus/modbus.h | 99 +++--- .../components/modbus_client/modbus_client.h | 29 +- .../modbus_controller/modbus_controller.cpp | 15 +- esphome/components/pzemac/pzemac.cpp | 2 +- esphome/components/pzemdc/pzemdc.cpp | 2 +- tests/components/modbus/heap_probe_test.cpp | 8 +- .../modbus/modbus_client_hub_test.cpp | 286 ++++++++++-------- 8 files changed, 260 insertions(+), 195 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index c9e443cd87..9f2527d9fb 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -883,8 +883,8 @@ void ModbusClientHub::sweep_() { } // Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. -bool ModbusClientHub::send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, - CommandOptions options) { +bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device, + CommandOptions options) { // Requests refused here never enter the machine and get no callback - the false return is it. if (pdu.empty()) { ESP_LOGW(TAG, "Empty PDU refused for address %" PRIu8, address); @@ -995,7 +995,7 @@ void ModbusClientHub::send_raw(const std::vector &payload, ModbusClient ESP_LOGW(TAG, "send_raw() payload too short to contain a PDU, refused"); return; } - this->send_pdu(payload[0], std::span(payload).subspan(1), device); + this->queue_pdu(payload[0], std::span(payload).subspan(1), device); } // Send raw command for server replies immediately. Except CRC everything must be contained in payload @@ -1077,7 +1077,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // - On failure (status engaged) the response is empty by design (see on_error()), so only the request // is validated. bool custom = !helpers::is_client_pdu_standard(request_pdu.data(), request_pdu.size()); - if (!custom && !status.has_value()) { + if (!custom && succeeded(status)) { custom = !helpers::is_server_pdu_standard(response_pdu.data(), response_pdu.size()); if (!custom && helpers::is_function_code_read(static_cast(function_code))) { const bool bits = @@ -1104,7 +1104,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On // failure the registers span is empty. RegisterValues registers; - if (!status.has_value()) { + if (succeeded(status)) { for (size_t i = 0; i != count_or_value; i++) { registers.push_back(helpers::get_data(response_pdu.data(), 2 + 2 * i)); } @@ -1124,7 +1124,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // PackedBits::operator[] is unchecked, so size() must never promise bits with no bytes behind them. std::span packed_bytes; uint16_t count = 0; - if (!status.has_value()) { + if (succeeded(status)) { packed_bytes = response_pdu.subspan(2); count = count_or_value; } @@ -1141,7 +1141,7 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu // copy. On an exception the response has no value and the request copy is the only one. case FunctionCode::WRITE_SINGLE_REGISTER: case FunctionCode::WRITE_SINGLE_COIL: { - const uint16_t value = (!status.has_value() && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) + const uint16_t value = (succeeded(status) && response_pdu.size() >= WRITE_SINGLE_PDU_SIZE) ? helpers::get_data(response_pdu.data(), 3) : count_or_value; if (function_code == FunctionCode::WRITE_SINGLE_REGISTER) { diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 274b10f9b4..3b6028e90a 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -262,19 +262,31 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->send_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); + this->queue_pdu(address, + helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, + payload_len), + device); }; - // Queue a request; true once it is a live entry (resolving in one terminal), false if it never - // entered the machine (empty/oversize PDU, full queue, anonymous or over-cap duplicate) - no callback. - bool send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, - CommandOptions options = {}); - ESPDEPRECATED("Use send_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") + /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and + /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in + /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap + /// duplicate) and no callback of any kind will follow; the false return is the whole story. + bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, + CommandOptions options = {}); + // Remove before 2027.2.0. Deliberately the signature 2026.7.4 shipped - void, and no CommandOptions: + // the bool return and the options argument arrived after that release, so nothing external can be + // relying on them under this name. Callers who want the queued/refused answer move to queue_pdu(). + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr) { + this->queue_pdu(address, pdu, device); + } + ESPDEPRECATED("Use queue_pdu(payload[0], , device) instead. Removed in 2027.2.0", "2026.8.0") void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); // Clear an address's commands; each un-run request resolves via on_not_sent(), but a frame on the // wire still runs to its usual terminal. clear_tx_queue_for_device() instead discards silently. @@ -315,6 +327,12 @@ class ModbusClientHub : public Modbus { // Transaction status: std::nullopt on success, otherwise a Modbus exception code using ResponseStatus = std::optional; +/// True when a transaction carried no exception. The optional holds the exception, so has_value() means +/// the request FAILED - the inverse of how "status" usually reads. Prefer this at the call site; the +/// bare !status.has_value() has already been mistaken for a failure check more than once. Where the code +/// is going to unwrap the exception anyway, status.has_value() followed by status.value() stays clearer. +inline bool succeeded(ResponseStatus status) { return !status.has_value(); } + // Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol // maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by // the capacity of this type. @@ -373,7 +391,7 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by -/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return) +/// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) /// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. /// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all /// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from @@ -383,7 +401,7 @@ class ModbusServerHub : public Modbus { /// merges into it). /// /// Invariants: -/// - Public entry points (send_pdu/clear_tx_queue_*) only append to the queue or mutate an existing +/// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing /// entry through its callback-free transition methods. /// - Public entry points can never trigger a callback synchronously. /// - Callbacks are delivered only from within loop(). @@ -485,66 +503,75 @@ class ModbusClientDevice { /// to handle custom traffic (which also silences the warning). virtual void on_custom_response(std::span request_pdu, std::span response_pdu, ResponseStatus status); - ESPDEPRECATED("Use the typed read_*/write_* helpers or send_pdu() instead. Removed in 2027.2.0", "2026.8.0") + ESPDEPRECATED("Use the typed read_*/write_* helpers or queue_pdu() instead. Removed in 2027.2.0", "2026.8.0") void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, const uint8_t *payload = nullptr) { - this->parent_->send_pdu( + this->parent_->queue_pdu( this->address_, helpers::create_client_pdu((FunctionCode) function, start_address, number_of_entities, payload, payload_len), this); } - /// See ModbusClientHub::send_pdu(): true = accepted (a terminal callback will follow), - /// false = refused at the door (no callback). - bool send_pdu(std::span pdu, CommandOptions options = {}) { - return this->parent_->send_pdu(this->address_, pdu, this, options); + /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will + /// follow, false = refused at the door and nothing further happens. Neither means the frame is on + /// the wire; on_sent() reports that. + bool queue_pdu(std::span pdu, CommandOptions options = {}) { + return this->parent_->queue_pdu(this->address_, pdu, this, options); } - ESPDEPRECATED("Use send_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") - bool send_raw(const std::vector &payload) { + // Remove before 2027.2.0. As on the hub, this is the signature 2026.7.4 shipped: void, no options. + ESPDEPRECATED("Use queue_pdu() instead - the call queues a request, it does not send one, and it " + "reports whether the request was accepted. Removed in 2027.2.0", + "2026.8.0") + void send_pdu(std::span pdu) { this->queue_pdu(pdu); } + ESPDEPRECATED("Use queue_pdu() instead (the device address is prepended for you). Removed in 2027.2.0", "2026.8.0") + void send_raw(const std::vector &payload) { if (payload.empty()) - return false; // too short to contain a PDU; refused at the door like any invalid send - return this->parent_->send_pdu(payload[0], std::span(payload).subspan(1), this); + return; // too short to contain a PDU; refused at the door like any invalid send + this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } + // The typed request builders below all queue through queue_pdu(), so they share its contract: true + // means the request is queued and will resolve in exactly one terminal callback, false means it was + // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which - // create_read_pdu() rejects into an empty PDU and send_pdu() refuses with a false return. + // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, - number_of_entities), - options); + return this->queue_pdu(helpers::create_read_pdu(helpers::modbus_register_read_function(entity_type), start_address, + number_of_entities), + options); } bool read_input_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_INPUT_REGISTERS, start_address, number_of_registers), options); } bool read_holding_registers(uint16_t start_address, uint16_t number_of_registers, CommandOptions options = {}) { - return this->send_pdu( + return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_HOLDING_REGISTERS, start_address, number_of_registers), options); } bool read_coils(uint16_t start_address, uint16_t number_of_coils, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); + return this->queue_pdu(helpers::create_read_pdu(FunctionCode::READ_COILS, start_address, number_of_coils), options); } bool read_discrete_inputs(uint16_t start_address, uint16_t number_of_inputs, CommandOptions options = {}) { - return this->send_pdu(helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), - options); + return this->queue_pdu( + helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } bool write_single_register(uint16_t start_address, uint16_t value) { - return this->send_pdu(helpers::create_write_single_register_pdu(start_address, value)); + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); } bool write_single_coil(uint16_t address, bool value) { - return this->send_pdu(helpers::create_write_single_coil_pdu(address, value)); + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); } bool write_multiple_registers(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->send_pdu(helpers::create_write_coils_pdu(start_address, bits)); + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 599be85cb8..f9a00d65f6 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -33,7 +33,11 @@ template class ClientActionBase : public Action, public m /// The frame was written to the wire: fires once per transmission, before any reply, and never for a /// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data). void on_sent(std::span request_pdu) override { this->sent_trigger_.trigger(request_pdu); } - /// Never reached the wire (tx queue full, cleared, or a duplicate write dropped by the hub's dedup). + /// Never reached the wire, from either of two sources. The hub calls this for a request it accepted + /// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus + /// device going offline, say. Everything the hub refuses at the door instead returns false from + /// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same + /// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder. void on_not_sent(std::span request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); } /// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing: /// register_client_action() wires on_error for all of them, so a derived class must not have to @@ -64,7 +68,7 @@ template class ClientActionBase : public Action, public m /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { - if (!this->send_pdu(pdu)) + if (!this->queue_pdu(pdu)) this->on_not_sent(pdu); } @@ -107,7 +111,9 @@ template class ModbusClientSendAction : public ClientActionBase< /// valid for the duration of the trigger. (For a typed-built request the gate can only divert on the /// response, never with an exception status - real device exceptions arrive via on_error, which /// ClientActionBase already routes straight to its trigger, so the typed callbacks below only ever see a -/// success status.) +/// success status.) Each typed callback still checks succeeded() before firing its trigger: that branch +/// is unreachable today, and is kept so a future change to that interception cannot silently deliver an +/// exception as a successful reply. template class TypedClientActionBase : public ClientActionBase { public: Trigger, std::span> *get_custom_response_trigger() { @@ -127,11 +133,6 @@ template class TypedClientActionBase : public ClientActionBase, std::span> custom_response_trigger_; bool custom_response_handled_{false}; }; @@ -154,7 +155,7 @@ template class ReadRegistersAction : public TypedClientActionBas } void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(registers); } @@ -181,7 +182,7 @@ template class ReadBitsAction : public TypedClientActionBaseis_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(bits); } @@ -204,7 +205,7 @@ template class WriteSingleRegisterAction : public TypedClientAct modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -226,7 +227,7 @@ template class WriteSingleCoilAction : public TypedClientActionB modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -270,7 +271,7 @@ template class WriteMultipleRegistersAction : public TypedClient } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } @@ -318,7 +319,7 @@ template class WriteMultipleCoilsAction : public TypedClientActi } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { - if (this->is_success_(status)) + if (modbus::succeeded(status)) this->response_trigger_.trigger(); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c4161d454f..da9d29887e 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -160,10 +160,11 @@ void ModbusController::queue_command(ModbusCommandItem command) { } void ModbusController::unqueue_command(const ModbusCommandItem *command) { - // Called as the last action of the command's own callback, and from send() after send_pdu (which may - // synchronously call on_not_sent). Destroying `command` here would leave send() and the hub touching a - // freed object, so we only FLAG it; sweep_completed_one_shots_() erases it later at a safe point. No-op - // for polling commands (they persist and are not in the one-shot list). + // Called as the last action of the command's own callback (on_response/on_error/on_not_sent/ + // on_no_response), which the hub runs from inside its sweep while this entry is still live. + // Destroying `command` here would leave the hub touching a freed object, so we only FLAG it; + // sweep_completed_one_shots_() erases it later at a safe point. No-op for polling commands + // (they persist and are not in the one-shot list). for (auto &item : this->one_shot_command_items_) { if (item.get() == command) { item->pending_removal = true; @@ -494,13 +495,13 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( bool ModbusCommandItem::send() { bool accepted; if (this->function_code_ != FunctionCode::CUSTOM) { - accepted = this->send_pdu(modbus::helpers::create_client_pdu( + accepted = this->queue_pdu(modbus::helpers::create_client_pdu( this->function_code_, this->start_address_, this->register_count_, this->payload.empty() ? nullptr : this->payload.data(), this->payload.size())); } else { // Custom command: the bytes are a complete raw frame (address + PDU). Send the PDU to the frame's own // address (which may differ from this controller's); the hub appends the CRC and routes the response - // back to this item by pointer. (send_raw() is deprecated, so send_pdu() is called with the extracted + // back to this item by pointer. (send_raw() is deprecated, so queue_pdu() is called with the extracted // address. Raw-frame semantics are kept here; the custom_pdu migration is a later step.) std::span frame = this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; @@ -508,7 +509,7 @@ bool ModbusCommandItem::send() { ESP_LOGW(TAG, "Empty custom command frame, not sent"); accepted = false; } else { - accepted = this->parent_->send_pdu(frame[0], frame.subspan(1), this); + accepted = this->parent_->queue_pdu(frame[0], frame.subspan(1), this); } } // The on_command_sent trigger fires from on_sent() when the frame actually reaches the wire. diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 5651e07af0..d817888922 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { void PZEMAC::reset_energy_() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemac diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 5e505cde0c..926ad83f09 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -65,7 +65,7 @@ void PZEMDC::dump_config() { void PZEMDC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; - this->send_pdu(pdu); + this->queue_pdu(pdu); } } // namespace esphome::pzemdc diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp index ddf905a8df..869b280b0d 100644 --- a/tests/components/modbus/heap_probe_test.cpp +++ b/tests/components/modbus/heap_probe_test.cpp @@ -134,7 +134,7 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { size_t total = 0; for (int i = 0; i != n; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - total += sample([&] { device.send_pdu(req); }).count; + total += sample([&] { device.queue_pdu(req); }).count; } printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); EXPECT_EQ(total, 0u); @@ -151,11 +151,11 @@ TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) { req.assign(read_pdu, read_pdu + sizeof(read_pdu)); for (int i = 0; i != 3; i++) { req[2] = static_cast(i); // distinct start addresses: identical frames would dedup, not enqueue - device.send_pdu(req); + device.queue_pdu(req); } const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - Sample append = sample([&] { device.send_pdu(write_pdu); }); + Sample append = sample([&] { device.queue_pdu(write_pdu); }); printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes); EXPECT_EQ(append.count, 0u); } @@ -180,7 +180,7 @@ TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) { const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; auto round_trip = [&](std::span response_pdu) { - device.send_pdu(req); + device.queue_pdu(req); hub.loop(); // transmit; the tx queue is empty during the measured receive below uart.inject_frame(0x02, response_pdu); return sample([&] { hub.loop(); }); // receive + parse + match + dispatch diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 4d5b4e7ee8..c2a36c0da7 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -116,7 +116,7 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); hub.force_send_next(); ASSERT_EQ(hub.queued_frames(), 0u); @@ -141,7 +141,7 @@ TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -157,7 +157,7 @@ TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { NoResponseProbeHub hub; { RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // device destructor clears its queue entries, including the waiting frame's device pointer } @@ -177,7 +177,7 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. @@ -204,7 +204,7 @@ TEST(ModbusClientHubNoResponse, InterruptedShellDeclinedRetryRetiresOnRelease) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; @@ -229,7 +229,7 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { NoResponseProbeHub hub; ClearingRetryDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -246,9 +246,9 @@ TEST(ModbusClientHubPriority, WritesSendBeforeQueuedReads) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(write_pdu); + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); hub.force_send_next(); @@ -266,8 +266,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedFrameAbsorbedNotDuplicated) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); // one entry standing for two accepted requests @@ -280,9 +280,9 @@ TEST(ModbusClientHubPriority, InFlightDuplicateRunsOnceMore) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/false); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame + device.queue_pdu(read_pdu()); // duplicate of the waiting frame EXPECT_EQ(hub.queued_frames(), 0u); // not queued twice EXPECT_EQ(hub.waiting_command().pending, 2u); @@ -303,9 +303,9 @@ TEST(ModbusClientHubPriority, AbsorbedRequestSurvivesDeviceRetry) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed + device.queue_pdu(read_pdu()); // duplicate of the waiting frame -> absorbed ASSERT_EQ(hub.waiting_command().pending, 2u); hub.timeout_waiting(); // no response; the device requests a retry @@ -459,8 +459,8 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) { device.read_holding_registers(0x100, 2, {.continuous = true}); const uint8_t one_shot[] = {0x03, 0x02, 0x00, 0x00, 0x01}; const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(one_shot); - device.send_pdu(write_pdu); + device.queue_pdu(one_shot); + device.queue_pdu(write_pdu); ASSERT_EQ(hub.queued_frames(), 3u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::CONTINUOUS); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); @@ -482,7 +482,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) { RetryingDevice device(&hub, 0x02, /*retry=*/false); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu, {.continuous = true}); + device.queue_pdu(write_pdu, {.continuous = true}); ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); EXPECT_FALSE(hub.queued(0).continuous); @@ -530,8 +530,8 @@ TEST(ModbusClientHubPriority, DuplicateQueuedWriteRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); // duplicate write: refused + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); // duplicate write: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -547,8 +547,8 @@ TEST(ModbusClientHubPriority, DuplicateCustomFunctionCodeRefused) { SentCountingDevice device(&hub, 0x02); const uint8_t custom_pdu[] = {0x41, 0x01, 0x02}; // user-defined function code - EXPECT_TRUE(device.send_pdu(custom_pdu)); - EXPECT_FALSE(device.send_pdu(custom_pdu)); // duplicate custom command: refused + EXPECT_TRUE(device.queue_pdu(custom_pdu)); + EXPECT_FALSE(device.queue_pdu(custom_pdu)); // duplicate custom command: refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -562,8 +562,8 @@ TEST(ModbusClientHubPriority, AnonymousDuplicateDroppedNotPromoted) { NoResponseProbeHub hub; const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - hub.send_pdu(0x02, read); - hub.send_pdu(0x02, read); // anonymous duplicate: dropped + hub.queue_pdu(0x02, read); + hub.queue_pdu(0x02, read); // anonymous duplicate: dropped ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 1u); // never absorbed for a null owner @@ -575,13 +575,13 @@ TEST(ModbusClientHubPriority, RetriedReadGoesBehindFreshReads) { NoResponseProbeHub hub; RetryingDevice device(&hub, 0x02, /*retry=*/true); - device.send_pdu(read_pdu()); - hub.force_send_next(); // the frame that will time out and retry - device.send_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry + device.queue_pdu(read_pdu()); + hub.force_send_next(); // the frame that will time out and retry + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed into the waiting entry const uint8_t fresh_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t fresh_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(fresh_a); - device.send_pdu(fresh_b); + device.queue_pdu(fresh_a); + device.queue_pdu(fresh_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.timeout_waiting(); // device retries; the entry returns to READY behind the fresh reads @@ -608,9 +608,9 @@ TEST(ModbusClientHubPriority, AbsorbedDuplicateKeepsPlaceInLine) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - device.send_pdu(read_a); - device.send_pdu(read_b); - device.send_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged + device.queue_pdu(read_a); + device.queue_pdu(read_b); + device.queue_pdu(read_a); // duplicate of the older entry: absorbed, place unchanged ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); @@ -626,14 +626,14 @@ TEST(ModbusClientHubPriority, RetriedWriteKeepsWritePriorityAndStaysNonRequeueab RetryingDevice device(&hub, 0x02, /*retry=*/true); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(write_pdu); + device.queue_pdu(write_pdu); hub.force_send_next(); hub.timeout_waiting(); // no response -> device requests retry -> back to READY ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); // retry preserves the WRITE class - device.send_pdu(write_pdu); // duplicate of the retried write + device.queue_pdu(write_pdu); // duplicate of the retried write ASSERT_EQ(hub.queued_frames(), 1u); // still not queued twice... EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE); hub.sweep_for_test(); @@ -655,7 +655,7 @@ TEST(ModbusClientHubSent, BlockedHubDefersInsteadOfFailing) { AlwaysBlockedHub hub; SentCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); hub.send_next_for_test(); EXPECT_EQ(device.sent_count_, 0); @@ -673,7 +673,7 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { hub.setup(); // frame timing derives from the baud rate SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); EXPECT_EQ(device.sent_count_, 0); // queued only - nothing sent yet hub.send_next_for_test(); @@ -703,7 +703,7 @@ TEST(ModbusClientHubSent, SendRejectedAfterDelayLeavesFrameReady) { hub.setup(); SentCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // gate passes, send_frame_ rejects on the post-delay re-check EXPECT_EQ(device.sent_count_, 0); // nothing transmitted @@ -766,7 +766,7 @@ TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(device.data_count_, 1); @@ -781,8 +781,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateReadExactlyTwoCallbacks) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); int cycles = drain_with_responses(hub, OK_RESPONSE); EXPECT_EQ(cycles, 2); @@ -812,8 +812,8 @@ TEST(ModbusClientHubCallbackCount, ClearFromResponseResolvesDuplicateWithNotSent NoResponseProbeHub hub; ClearOnFirstResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, pending 2 + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, pending 2 hub.force_send_next(); hub.receive_frame_for_test(0x02, OK_RESPONSE); // response -> on_response -> clear, then sweep @@ -829,9 +829,9 @@ TEST(ModbusClientHubCallbackCount, TripleReadRefusesTheThird) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_FALSE(device.send_pdu(read_pdu())); // the entry is already at its cap + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_FALSE(device.queue_pdu(read_pdu())); // the entry is already at its cap hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // refused synchronously, nothing owed int cycles = drain_with_responses(hub, OK_RESPONSE); @@ -849,8 +849,8 @@ TEST(ModbusClientHubCallbackCount, DuplicateWriteRefusedWithoutLifecycle) { DataCountingDevice device(&hub, 0x02); const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.terminals(), 0); // the accepted write has not resolved; the other never existed @@ -867,7 +867,7 @@ TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); @@ -886,7 +886,7 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); EXPECT_EQ(device.no_response_count_, 1); @@ -896,8 +896,8 @@ TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) // Unabsorbable duplicate: the second identical write is refused at the door - no lifecycle, no // terminal, nothing sent. const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF}; - EXPECT_TRUE(device.send_pdu(write_pdu)); - EXPECT_FALSE(device.send_pdu(write_pdu)); + EXPECT_TRUE(device.queue_pdu(write_pdu)); + EXPECT_FALSE(device.queue_pdu(write_pdu)); hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); EXPECT_EQ(device.terminals(), 1); // still just the read's timeout @@ -920,7 +920,7 @@ TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) { DataCountingDevice device(&hub, 0x02); device.retries_ = 1; // ask for exactly one retry - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued) ASSERT_EQ(hub.queued_frames(), 1u); @@ -946,13 +946,13 @@ TEST(ModbusClientHubCallbackCount, RetryIsNeverRefusedByFullQueue) { device.retries_ = 1; SentCountingDevice filler(&hub, 0x05); - device.send_pdu(read_pdu()); - hub.force_send_next(); // waiting - device.send_pdu(read_pdu()); // absorbed: two requests pending + device.queue_pdu(read_pdu()); + hub.force_send_next(); // waiting + device.queue_pdu(read_pdu()); // absorbed: two requests pending // Fill the remaining live capacity with distinct frames. for (uint16_t i = 0; hub.entries() < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } hub.timeout_waiting(); // retry requested; the entry flips back to READY regardless of capacity @@ -991,10 +991,10 @@ TEST(ModbusClientHubQueue, SendRawTooShortIsRefusedAtTheDoor) { NotSentCountingRawDevice device(&hub, 0x02); #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - EXPECT_FALSE(device.send_raw({})); // too short to contain a PDU + device.send_raw({}); // too short to contain a PDU; the deprecated void spelling cannot report it #pragma GCC diagnostic pop - EXPECT_EQ(device.not_sent_count_, 0); // refusals are returned, never delivered - EXPECT_TRUE(hub.tx_buffer_empty()); + EXPECT_EQ(device.not_sent_count_, 0); // refused at the door: no callback delivered + EXPECT_TRUE(hub.tx_buffer_empty()); // the only evidence of the refusal is that nothing queued } // A continuous read: every wire transmission pairs one sent with one terminal, ending on the error. @@ -1040,7 +1040,7 @@ class ChainOnSentDevice : public ModbusClientDevice { if (!this->chained_) { this->chained_ = true; const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1 - this->send_pdu(follow); + this->queue_pdu(follow); } } bool chained_{false}; @@ -1059,9 +1059,9 @@ TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02}; - controller_like.send_pdu(read_a); - bystander_same.send_pdu(read_b); - bystander_other.send_pdu(read_c); + controller_like.queue_pdu(read_a); + bystander_same.queue_pdu(read_b); + bystander_other.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); controller_like.clear_tx_queue_for_address(); @@ -1083,8 +1083,8 @@ TEST(ModbusClientHubQueue, ClearAddressDeliversOneTerminalPerAcceptedRequest) { SentCountingDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x01, 0x00, 0x00, 0x02}; - device.send_pdu(read); - device.send_pdu(read); // duplicate: absorbed into the queued entry + device.queue_pdu(read); + device.queue_pdu(read); // duplicate: absorbed into the queued entry ASSERT_EQ(hub.queued_frames(), 1u); ASSERT_EQ(hub.queued(0).pending, 2u); @@ -1103,8 +1103,8 @@ TEST(ModbusClientHubQueue, ClearSentOnInFlightDuplicateStillNotifiesTheDuplicate NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // the frame is sent (WAITING); pending still 2 ASSERT_TRUE(hub.waiting()); @@ -1122,7 +1122,7 @@ TEST(ModbusClientHubQueue, ClearWhileInFlightStillDeliversTheResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // sent, now WAITING ASSERT_TRUE(hub.waiting()); @@ -1151,7 +1151,7 @@ class ResendOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01}; // a write: ranked first at selection, not by position - this->send_pdu(again); + this->queue_pdu(again); } } int not_sent_count_{0}; @@ -1165,7 +1165,7 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) { ResendOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1187,8 +1187,8 @@ TEST(ModbusClientHubQueue, ClearAddressReentrantResendNotSwept) { const uint8_t read_victim[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - resender.send_pdu(read_victim); - bystander_other.send_pdu(read_other); + resender.queue_pdu(read_victim); + bystander_other.queue_pdu(read_other); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1212,13 +1212,14 @@ class AlwaysResendDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); } int not_sent_count_{0}; }; -// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device -// guard suppresses deliveries only to a device already inside its own on_not_sent()). +// From inside on_not_sent, clears ANOTHER address - those victims must still be notified. Nothing +// suppresses that: a re-entrant clear only flips states, retire() is a no-op on an already-retired +// entry, and each entry still owes one notification per un-run request until pending reaches zero. class ClearOtherOnNotSentDevice : public ModbusClientDevice { public: ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} @@ -1238,9 +1239,9 @@ TEST(ModbusClientHubQueue, PendingNeverExceedsTheServableCap) { AlwaysResendDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x50, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_TRUE(device.send_pdu(read)); - EXPECT_FALSE(device.send_pdu(read)); // at the cap: refused + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_TRUE(device.queue_pdu(read)); + EXPECT_FALSE(device.queue_pdu(read)); // at the cap: refused ASSERT_EQ(hub.queued_frames(), 1u); EXPECT_EQ(hub.queued(0).pending, 2u); @@ -1260,12 +1261,12 @@ TEST(ModbusClientHubQueue, FullQueueRefusesWithoutCallbacks) { // Fill the queue with distinct frames (distinct start addresses keep the dedup from absorbing them). for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) { const uint8_t fill[] = {0x03, static_cast(i >> 8), static_cast(i & 0xFF), 0x00, 0x01}; - filler.send_pdu(fill); + filler.queue_pdu(fill); } ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - EXPECT_FALSE(device.send_pdu(read)); // refused synchronously + EXPECT_FALSE(device.queue_pdu(read)); // refused synchronously hub.sweep_for_test(); EXPECT_EQ(device.not_sent_count_, 0); // nothing was accepted, so nothing is owed @@ -1298,13 +1299,13 @@ TEST(ModbusClientHubQueue, SelfClearFromNotSentResolvesEveryRequest) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01}; - clearer.send_pdu(read_a); - clearer.send_pdu(read_b); - bystander.send_pdu(read_c); + clearer.queue_pdu(read_a); + clearer.queue_pdu(read_b); + bystander.queue_pdu(read_c); ASSERT_EQ(hub.queued_frames(), 3u); - EXPECT_FALSE(clearer.send_pdu(std::span{})); // empty: refused, no callback - clearer.clear_tx_queue_for_address(); // the clear the handler used to make + EXPECT_FALSE(clearer.queue_pdu(std::span{})); // empty: refused, no callback + clearer.clear_tx_queue_for_address(); // the clear the handler used to make hub.sweep_for_test(); @@ -1323,8 +1324,8 @@ TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) { const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01}; - clearer.send_pdu(read_a); - victim.send_pdu(read_b); + clearer.queue_pdu(read_a); + victim.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); // clearer's on_not_sent clears address 0x03 in turn @@ -1345,7 +1346,7 @@ class ResendSecondFrameDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - this->send_pdu(same_as_r2); + this->queue_pdu(same_as_r2); } } int not_sent_count_{0}; @@ -1360,8 +1361,8 @@ TEST(ModbusClientHubQueue, SweepDedupSkipsDeletedFrames) { const uint8_t r1[] = {0x03, 0x00, 0x21, 0x00, 0x01}; const uint8_t r2[] = {0x03, 0x00, 0x22, 0x00, 0x01}; - device.send_pdu(r1); - device.send_pdu(r2); + device.queue_pdu(r1); + device.queue_pdu(r2); ASSERT_EQ(hub.queued_frames(), 2u); hub.clear_tx_queue_for_address(0x02); @@ -1383,7 +1384,7 @@ class ResendAndClearOnNotSentDevice : public ModbusClientDevice { void on_not_sent(std::span request_pdu) override { this->not_sent_count_++; const uint8_t again[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - this->send_pdu(again); + this->queue_pdu(again); this->clear_tx_queue_for_address(); } int not_sent_count_{0}; @@ -1397,7 +1398,7 @@ TEST(ModbusClientHubQueue, ResendAndClearFromNotSentCannotExtendTheSweep) { ResendAndClearOnNotSentDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x70, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); @@ -1421,8 +1422,8 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02}; const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02}; - device.send_pdu(read_a); - device.send_pdu(read_b); + device.queue_pdu(read_a); + device.queue_pdu(read_b); ASSERT_EQ(hub.queued_frames(), 2u); device.clear_tx_queue_for_device(); @@ -1431,7 +1432,7 @@ TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) { EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback } -// A send_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending +// A queue_pdu() from inside on_sent() enqueues behind the waiting frame rather than sending // immediately or corrupting the waiting transaction. TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { NullUART uart; @@ -1440,7 +1441,7 @@ TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) { hub.setup(); ChainOnSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.send_next_for_test(); // first frame is sent -> on_sent chains a follow-up EXPECT_TRUE(hub.waiting()); // first frame is waiting @@ -1507,34 +1508,69 @@ class LegacyNameDevice : public ModbusClientDevice { #pragma GCC diagnostic pop } // namespace +// send_pdu() was renamed queue_pdu() because the call queues a request rather than transmitting one. +// The old spelling stays for the deprecation window with the signature 2026.7.4 shipped - void, no +// CommandOptions - so a component built against a real release still compiles and still queues. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +TEST(ModbusClientHubCompat, DeprecatedSendPduStillQueues) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; + device.send_pdu(read); // deprecated device spelling: void, as 2026.7.4 shipped it + EXPECT_EQ(hub.queued_frames(), 1u); + + // A refusal is invisible to this spelling - no return value and no callback - so the only evidence + // is that nothing was queued. Reporting the refusal is exactly what moving to queue_pdu() buys. + device.send_pdu(std::span()); + EXPECT_EQ(hub.queued_frames(), 1u); + + // The deprecated hub spelling queues the same way, addressed explicitly. + const uint8_t other[] = {0x03, 0x00, 0x20, 0x00, 0x01}; + hub.send_pdu(0x03, other, &device); + EXPECT_EQ(hub.queued_frames(), 2u); + + // Both frames resolve to the same owner. Drain them in turn: the device-spelling frame first (FIFO), + // then the hub-spelling frame - addressed to 0x03 yet owned by &device, so reaching device's + // on_no_response proves the request routes by owner pointer, not by address. + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); // device-spelling frame (address 0x02) + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 2); // hub-spelling frame (address 0x03, &device routing) +} +#pragma GCC diagnostic pop + TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(read); + device.queue_pdu(read); hub.force_send_next(); hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response EXPECT_EQ(device.legacy_no_response_, 1); // A refused send returns false with no callback, so exercise the forward through an accepted // request instead: a cleared queue entry delivers on_not_sent(), which forwards to the old name. - EXPECT_FALSE(device.send_pdu(std::span())); // empty PDU: refused at the door + EXPECT_FALSE(device.queue_pdu(std::span())); // empty PDU: refused at the door EXPECT_EQ(device.legacy_not_sent_, 0); const uint8_t queued[] = {0x03, 0x00, 0x11, 0x00, 0x01}; - EXPECT_TRUE(device.send_pdu(queued)); + EXPECT_TRUE(device.queue_pdu(queued)); hub.clear_tx_queue_for_address(0x02); hub.sweep_for_test(); EXPECT_EQ(device.legacy_not_sent_, 1); } -// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU +// The queue_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU // 256-byte limit, so it is refused up front - false at the call site, no entry, no callback. TEST(ModbusClientHub, OversizedPduIsRefusedAtTheDoor) { NoResponseProbeHub hub; LegacyNameDevice device(&hub, 0x02); std::vector big(MAX_PDU_SIZE + 1, 0x41); - EXPECT_FALSE(device.send_pdu(big)); + EXPECT_FALSE(device.queue_pdu(big)); EXPECT_EQ(device.legacy_not_sent_, 0); // refusals are returned, never delivered EXPECT_TRUE(hub.tx_buffer_empty()); EXPECT_EQ(hub.entries(), 0u); @@ -1568,7 +1604,7 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Read response: on_modbus_data() historically received the payload after the function code and // the byte-count byte, as an owning vector. const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02}; - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x02, response); @@ -1577,14 +1613,14 @@ TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) { // Write echo: no byte-count byte, so the payload is everything after the function code. const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A}; - device.send_pdu(write_req); + device.queue_pdu(write_req); hub.force_send_next(); hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request const std::vector expected_echo{0x00, 0x10, 0x00, 0x2A}; EXPECT_EQ(device.last_data_, expected_echo); // Exception response: on_modbus_error() received the masked function code and the exception code. - device.send_pdu(read_req); + device.queue_pdu(read_req); hub.force_send_next(); const uint8_t error[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, error); @@ -1675,9 +1711,9 @@ class ResendOnDataDevice : public ModbusClientDevice { public: ResendOnDataDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} void on_response(std::span request_pdu, std::span response_pdu) override { - this->send_pdu(std::vector(request_pdu.begin(), request_pdu.end())); + this->queue_pdu(std::vector(request_pdu.begin(), request_pdu.end())); } - void send_pdu(const std::vector &pdu) { ModbusClientDevice::send_pdu(pdu); } + void queue_pdu(const std::vector &pdu) { ModbusClientDevice::queue_pdu(pdu); } }; } // namespace @@ -1704,8 +1740,8 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { SentCountingDevice device(&hub, 0x02); const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged - EXPECT_TRUE(device.send_pdu(weird)); - EXPECT_FALSE(device.send_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused + EXPECT_TRUE(device.queue_pdu(weird)); + EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused hub.sweep_for_test(); ASSERT_EQ(hub.queued_frames(), 1u); @@ -1715,7 +1751,7 @@ TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) { // The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class // ordering either: exception-flagged codes are excluded from the mutates classification. const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF}; - device.send_pdu(weird_write); + device.queue_pdu(weird_write); ASSERT_EQ(hub.queued_frames(), 2u); EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE const ModbusDeviceCommand *next = hub.next_ready(); @@ -1732,7 +1768,7 @@ class ResendInFlightOnNotSentDevice : public ModbusClientDevice { this->not_sent_count_++; if (this->not_sent_count_ == 1) { const uint8_t same_as_waiting[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // == READ_PDU - this->send_pdu(same_as_waiting); + this->queue_pdu(same_as_waiting); } } int not_sent_count_{0}; @@ -1746,10 +1782,10 @@ TEST(ModbusClientHubQueue, SweepResendAfterClearQueuesFreshNotAbsorbedIntoShell) NoResponseProbeHub hub; ResendInFlightOnNotSentDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); // READ_PDU now waiting const uint8_t queued_read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; - device.send_pdu(queued_read); // a queued frame for the sweep to notify + device.queue_pdu(queued_read); // a queued frame for the sweep to notify ASSERT_EQ(hub.queued_frames(), 1u); hub.clear_tx_queue_for_address(0x02); @@ -1787,7 +1823,7 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseDoesNotDoubleResolve) { NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.timeout_waiting(); @@ -1802,8 +1838,8 @@ TEST(ModbusClientHubNoResponse, SelfClearFromNoResponseResolvesTheAbsorbedReques NoResponseProbeHub hub; ClearAddressOnNoResponseDevice device(&hub, 0x02); - EXPECT_TRUE(device.send_pdu(read_pdu())); - EXPECT_TRUE(device.send_pdu(read_pdu())); // absorbed: one entry, two requests + EXPECT_TRUE(device.queue_pdu(read_pdu())); + EXPECT_TRUE(device.queue_pdu(read_pdu())); // absorbed: one entry, two requests hub.force_send_next(); hub.timeout_waiting(); @@ -1818,7 +1854,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnLateResponse) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1836,7 +1872,7 @@ TEST(ModbusClientHubQueue, ClearedShellReleasesTheBusOnTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1856,7 +1892,7 @@ TEST(ModbusClientHubQueue, ClearInterruptedFrameGetsNoResponseAtTimeout) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); // declines the retry (retries_ == 0) - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; hub.receive_frame_for_test(0x07, stray_pdu); // wrong address: interrupts the transaction @@ -1887,7 +1923,7 @@ TEST(ModbusClientHubQueue, InterruptAfterClearStillDistrusts) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); hub.clear_tx_queue_for_address(0x02); ASSERT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); @@ -1912,8 +1948,8 @@ TEST(ModbusClientHubQueue, ClearedInFlightDuplicateTimesOutWithoutRerunning) { NoResponseProbeHub hub; DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); - device.send_pdu(read_pdu()); // absorbed: one entry, pending 2 + device.queue_pdu(read_pdu()); + device.queue_pdu(read_pdu()); // absorbed: one entry, pending 2 ASSERT_EQ(hub.queued(0).pending, 2u); hub.force_send_next(); // sent, pending still 2 hub.clear_tx_queue_for_address(0x02); @@ -1936,9 +1972,9 @@ TEST(ModbusClientHubCallbackCount, AbsorbedRequestRunsAfterErrorResponse) { hub.setup(); DataCountingDevice device(&hub, 0x02); - device.send_pdu(read_pdu()); + device.queue_pdu(read_pdu()); hub.force_send_next(); - device.send_pdu(read_pdu()); // waiting duplicate: absorbed + device.queue_pdu(read_pdu()); // waiting duplicate: absorbed const uint8_t exception_response[] = {0x83, 0x02}; hub.receive_frame_for_test(0x02, exception_response); // error terminal for request 1 @@ -1954,8 +1990,8 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01}; const uint8_t mask_write[] = {0x16, 0x00, 0x10, 0x00, 0xFF, 0x00, 0x01}; - device.send_pdu(read); - device.send_pdu(mask_write); + device.queue_pdu(read); + device.queue_pdu(mask_write); ASSERT_EQ(hub.queued_frames(), 2u); const ModbusDeviceCommand *next = hub.next_ready(); From 989dbd755007a7cd231721913ff50a4976f874c3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:10:38 +1200 Subject: [PATCH 044/597] [ci] Name release runs after the version or dev tag they build (#18110) --- .github/workflows/release-nightly.yml | 38 +++++++++++++++++++++++++++ .github/workflows/release.yml | 36 ++++++++++++++++++++----- 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-nightly.yml diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000..cd3b7207b7 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,38 @@ +--- +name: Nightly Dev Release + +# Works out the dated dev tag and starts the release workflow with it, so that +# the release run is named after the tag it builds. A workflow run name is +# fixed when the run starts and cannot read a file or the current date. + +on: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read # actions/checkout to read the version from esphome/const.py + +jobs: + trigger: + name: Start release build + if: github.repository == 'esphome/esphome' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout to read the version from esphome/const.py + actions: write # gh workflow run starts release.yml + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Start the release workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py) + if [[ -z "$VERSION" ]]; then + echo "::error::Could not read __version__ from esphome/const.py" + exit 1 + fi + TAG="${VERSION}$(date --utc '+%Y%m%d')" + echo "Starting release build for ${TAG}" + gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 839b805237..10b28ace38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,23 @@ --- name: Publish Release +# Releases (production and beta) are named after the version they publish. +# Dev builds are named after the dated dev tag, which is passed in by the +# nightly workflow because a run name cannot compute it itself. +run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }} + on: workflow_dispatch: + inputs: + tag: + description: >- + Tag to build. Only supported on dev, where the nightly workflow + uses it. Leave empty to build the version from esphome/const.py + with today's date appended. + required: false + default: "" release: types: [published] - schedule: - - cron: "0 2 * * *" permissions: contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write @@ -23,6 +34,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} # yamllint disable rule:line-length run: | if [[ "${{ github.event_name }}" = "release" ]]; then @@ -34,12 +47,23 @@ jobs: ENVIRONMENT="production" fi else - TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") - today="$(date --utc '+%Y%m%d')" - TAG="${TAG}${today}" BRANCH=${GITHUB_REF#refs/heads/} + # The nightly workflow passes the finished tag so that the run name + # matches what is built. Without it, work it out here. + TAG="${INPUT_TAG}" + if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then + echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images." + exit 1 + fi + if [[ -z "$TAG" ]]; then + TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") + today="$(date --utc '+%Y%m%d')" + TAG="${TAG}${today}" + if [[ "$BRANCH" != "dev" ]]; then + TAG="${TAG}-${BRANCH}" + fi + fi if [[ "$BRANCH" != "dev" ]]; then - TAG="${TAG}-${BRANCH}" BRANCH_BUILD="true" ENVIRONMENT="" else From 3de8c7f95c46bfb935dc62c5434e25b2056e0474 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:49:54 +0000 Subject: [PATCH 045/597] Bump bundled esphome-device-builder to 1.9.5 (#18223) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c0f7222bca..a4f5d3c3a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 RUN \ platformio settings set enable_telemetry No \ From 0f59ef36a9ed67fcc28fe8f943241f2bed71d15f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 00:26:41 -0500 Subject: [PATCH 046/597] [core] Store the validated-config cache as JSON to drop YAML off the upload fast path (#18106) --- esphome/compiled_config.py | 118 ++++-- esphome/core/__init__.py | 12 +- esphome/yaml_util.py | 2 + .../python/test_compiled_config_bench.py | 2 +- .../lazy_imports/upload_command_fast_path.py | 46 ++- tests/unit_tests/test_compiled_config.py | 339 +++++++++++++++--- tests/unit_tests/test_core.py | 27 ++ tests/unit_tests/test_lazy_imports.py | 18 +- 8 files changed, 459 insertions(+), 105 deletions(-) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 1bcd567b84..303af99e66 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,48 +1,69 @@ """Validated-config cache for the upload/logs fast path. -compile dumps the validated config to /storage/.validated.yaml; +compile dumps the validated config to /storage/.validated.json; the next upload/logs for that YAML reuses it instead of running the full -read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps -!lambda/!include/IDs/paths intact; mtime gates staleness. +read_config pipeline. The cache is deliberately lossy: only ``!lambda`` +bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses, +paths, UUIDs and enums store the same string form the YAML dumper +produced for them. JSON additionally coerces non-str dict keys to +strings; validated configs only use string keys (every schema key +validator is ``cv.string``). mtime gates staleness. """ from __future__ import annotations +import json import logging from pathlib import Path +from typing import Any -from esphome.core import CORE +from esphome.const import __version__ as ESPHOME_VERSION +from esphome.core import CORE, Lambda from esphome.helpers import write_file from esphome.storage_json import StorageJSON, ext_storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) +# Bump when the on-disk shape changes; a mismatched version falls back +# to read_config. The envelope also stamps the writing esphome version: +# after an upgrade the cache holds the previous release's validation, so +# it falls back once and the re-save self-heals. +_CACHE_VERSION = 1 +_LAMBDA_KEY = "__esphome_lambda__" + def compiled_config_path(config_filename: str) -> Path: """Path to the cached validated config alongside the storage sidecar.""" - return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" - - -def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: - """True iff the cache file exists and isn't older than the source.""" - try: - return cache_path.stat().st_mtime >= source_path.stat().st_mtime - except OSError: - return False + return CORE.data_dir / "storage" / f"{config_filename}.validated.json" def save_compiled_config(config: ConfigType) -> None: """Write the validated-config cache. Always-write so mtime stays fresh. - Mode 0600 because show_secrets=True resolves !secret inline. + Mode 0600 because config validation resolved !secret inline. Failures are non-fatal: the fast path falls back to read_config. """ - from esphome import yaml_util - try: - rendered = yaml_util.dump(config, show_secrets=True) + # The legacy YAML cache holds inline-resolved secrets and nothing + # reads it anymore; drop it even when the write below fails. A + # failed removal leaves resolved secrets on disk, so it warns. + try: + _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True) + except OSError as err: + _LOGGER.warning( + "Could not remove the legacy validated-config cache: %s", err + ) + rendered = json.dumps( + {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config}, + separators=(",", ":"), + default=_json_default, + ) write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except TypeError as err: + # Structural, not transient: this config can never cache (e.g. a + # non-basic dict key), so every upload/logs pays the slow path. + _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Skipping compiled config cache write: %s", err) @@ -51,25 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: """Load the cached validated config and apply storage metadata to CORE. Returns None (caller falls back to read_config) when the cache is - missing, older than the source YAML, unparseable, or the sidecar - is incomplete. + missing, older than the source YAML, unparseable, a different cache + version, or the sidecar is incomplete. The loaded config carries no + source ranges; callers must not feed it into read_config/write_cpp. """ cache_path = compiled_config_path(conf_path.name) if not _cache_is_fresh(cache_path, conf_path): return None - from esphome import yaml_util - try: - # Fast path never validates or generates code - no source ranges - # needed (see load_yaml). Callers must not feed this config into - # read_config/write_cpp: the esp_range consumers in config.py and - # cpp_generator.py are isinstance-guarded and would degrade - # silently (wrong error/lambda locations) instead of raising. - config = yaml_util.load_yaml( - cache_path, clear_secrets=False, track_document_range=False + envelope = json.loads( + cache_path.read_text(encoding="utf-8"), object_hook=_decode_object ) - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except (OSError, ValueError) as err: + _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err) + return None + + if ( + not isinstance(envelope, dict) + or envelope.get("v") != _CACHE_VERSION + or envelope.get("esphome") != ESPHOME_VERSION + or not isinstance(config := envelope.get("config"), dict) + ): + _LOGGER.debug("Ignoring compiled config cache with a foreign envelope") return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) @@ -81,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage.apply_to_core() return config + + +# Remove before 2027.8: by then every maintained install has saved the +# JSON cache at least once and dropped its legacy YAML file. +def _legacy_compiled_config_path(config_filename: str) -> Path: + """Path of the pre-JSON YAML cache; only ever removed.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def _json_default(value: Any) -> Any: + """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest + stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums). + + IncludeFile/Extend/Remove have no JSON mirror and would stringify + wrong, but none survive validation (config.py's packages merge and + the substitution pass consume them) so no guard is spent on them. + """ + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) + + +def _decode_object(obj: dict[str, Any]) -> Any: + """Revive the Lambda sentinel; every other mapping passes through.""" + if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str): + return Lambda(value) + return obj diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index e5b3ebb84d..1a5f4f2cf5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -321,14 +321,18 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: def __init__(self, value): - from esphome.cpp_generator import Expression, statement - # pylint: disable=protected-access if isinstance(value, Lambda): self._value = value._value - elif isinstance(value, Expression): - self._value = str(statement(value)) + elif isinstance(value, str): + # The validated-config cache revives Lambdas from strings on the + # upload/logs fast path; keep codegen off that path. + self._value = value else: + from esphome.cpp_generator import Expression, statement + + if isinstance(value, Expression): + value = str(statement(value)) self._value = value self._parts = None self._requires_ids = None diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 981e508d5d..d3c6caf60b 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1349,6 +1349,8 @@ class ESPHomeDumper(yaml.SafeDumper): return super().increase_indent(flow, False) +# Mirrored by compiled_config._json_default: a new representer that keeps a +# type round-trippable (like Lambda's) needs a sentinel there too. ESPHomeDumper.add_multi_representer( dict, lambda dumper, value: dumper.represent_mapping("tag:yaml.org,2002:map", value) ) diff --git a/tests/benchmarks/python/test_compiled_config_bench.py b/tests/benchmarks/python/test_compiled_config_bench.py index 5c8892f8d0..4d7821f704 100644 --- a/tests/benchmarks/python/test_compiled_config_bench.py +++ b/tests/benchmarks/python/test_compiled_config_bench.py @@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None: Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``, then ``update_storage_json`` writes both the StorageJSON sidecar and - the ``.validated.yaml`` compiled-config cache. + the ``.validated.json`` compiled-config cache. """ CORE.config_path = yaml_path config = read_config({}, skip_external_update=True) diff --git a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py index f0df08aa4e..f70a3f85ac 100644 --- a/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py +++ b/tests/unit_tests/fixtures/lazy_imports/upload_command_fast_path.py @@ -2,12 +2,13 @@ Executed as a subprocess by test_lazy_imports.py: heavy module names come in on argv, the ones found in sys.modules afterwards go out on stdout. -Covers both fast-path claims: the bundle suffix check in run_esphome reads -BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, and -the real validated-config cache parse, include resolution included, stays -voluptuous free. +Covers three fast-path claims: the bundle suffix check in run_esphome reads +BUNDLE_EXTENSION from esphome.const without importing esphome.bundle, the +validated-config cache parse stays voluptuous free, and the JSON cache +(lambda sentinel included) resolves without pyyaml or esphome.yaml_util. """ +import json import os from pathlib import Path import sys @@ -16,7 +17,6 @@ from unittest.mock import patch from _leak_report import print_leaked_modules from _storage import make_storage -import yaml # Everything imported past this point is the code under test; the pop # below must only drop what the setup itself preloaded, or it would @@ -24,8 +24,10 @@ import yaml _FIXTURE_PRELOADED = frozenset(sys.modules) from esphome import __main__ as main_mod # noqa: E402 +from esphome.const import __version__ as ESPHOME_VERSION # noqa: E402 CONFIG_TEXT = "esphome:\n name: t\n" +LAMBDA_BODY = 'ESP_LOGD("t", "x");' # An ambient data-dir override would relocate the storage tree away # from the tmp config dir this fixture builds. @@ -39,13 +41,23 @@ with tempfile.TemporaryDirectory() as _td: storage_dir = tmp / ".esphome" / "storage" storage_dir.mkdir(parents=True) - # The cache is a top-level !include so loading it resolves an - # IncludeFile for real on the fast path. The sidecar is written to the - # layout ext_storage_path resolves once run_esphome sets - # CORE.config_path; going through CORE here would be circular. - (storage_dir / "inc.yaml").write_text(CONFIG_TEXT) - cache_path = storage_dir / "test.yaml.validated.yaml" - cache_path.write_text("!include inc.yaml\n") + # The cache carries a lambda sentinel so loading revives a real Lambda + # on the fast path. The sidecar is written to the layout + # ext_storage_path resolves once run_esphome sets CORE.config_path; + # going through CORE here would be circular. + cache_path = storage_dir / "test.yaml.validated.json" + cache_path.write_text( + json.dumps( + { + "v": 1, + "esphome": ESPHOME_VERSION, + "config": { + "esphome": {"name": "t"}, + "script": [{"lambda": {"__esphome_lambda__": LAMBDA_BODY}}], + }, + } + ) + ) os.utime(cache_path) # keep the cache at least as fresh as the source make_storage().save(storage_dir / "test.yaml.json") @@ -76,7 +88,13 @@ with tempfile.TemporaryDirectory() as _td: # asserts so PYTHONOPTIMIZE in the ambient environment can't strip them. if exit_code != 0: sys.exit(f"run_esphome exited {exit_code} before dispatching upload") - if dispatched.get("config") != yaml.safe_load(CONFIG_TEXT): - sys.exit(f"cache include did not resolve through the fast path: {dispatched!r}") + config = dispatched.get("config") + if config is None or config.get("esphome") != {"name": "t"}: + sys.exit(f"cache did not resolve through the fast path: {dispatched!r}") + from esphome.core import Lambda + + revived = config["script"][0]["lambda"] + if not isinstance(revived, Lambda) or revived.value != LAMBDA_BODY: + sys.exit(f"lambda sentinel did not revive: {revived!r}") print_leaked_modules() diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b852d2d596..b3c2170c3f 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,15 +2,20 @@ from __future__ import annotations +from ipaddress import IPv4Address, IPv4Network import json import os from pathlib import Path +from typing import Any from unittest.mock import patch +from uuid import UUID import pytest +from esphome import const, yaml_util from esphome.__main__ import run_esphome from esphome.compiled_config import ( + _LAMBDA_KEY, compiled_config_path, load_compiled_config, save_compiled_config, @@ -24,30 +29,26 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, KEY_VARIANT, + Toolchain, ) -from esphome.core import CORE -from esphome.yaml_util import ESPHomeDataBase +from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.util import OrderedDict -_VALIDATED_CONFIG_YAML = """\ -esphome: - name: lite_test - friendly_name: Lite Test Device -esp32: - board: nodemcu-32s -logger: - baud_rate: 115200 -api: - port: 6053 - encryption: - key: 6dGhpcyBpcyBhIHRlc3Q= -ota: - - platform: esphome - port: 3232 - password: secret -wifi: - ssid: ssid - use_address: 192.168.1.42 -""" +_VALIDATED_CONFIG = { + "esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"}, + "esp32": {"board": "nodemcu-32s"}, + "logger": {"baud_rate": 115200}, + "api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}}, + "ota": [{"platform": "esphome", "port": 3232, "password": "secret"}], + "wifi": {"ssid": "ssid", "use_address": "192.168.1.42"}, +} + + +def _cache_body(config: dict | None = None) -> str: + """Render the JSON envelope the production save writes.""" + return json.dumps( + {"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG} + ) def _write_storage( @@ -79,10 +80,10 @@ def _write_storage( storage_path.write_text(json.dumps(data), encoding="utf-8") -def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path: +def _write_cache(cache_path: Path, body: str | None = None) -> Path: """Write the cache file and return it.""" cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(body, encoding="utf-8") + cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8") return cache_path @@ -96,24 +97,28 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None: @pytest.fixture -def fresh_cache_files(tmp_path: Path) -> Path: - """YAML + StorageJSON + cache, all consistent and fresh.""" +def primed_storage(tmp_path: Path) -> Path: + """YAML + StorageJSON sidecar, no cache yet.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path - - storage_dir = tmp_path / ".esphome" / "storage" - _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") - _set_cache_mtime(cache, yaml_path, offset=5) - + _write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json") return yaml_path +@pytest.fixture +def fresh_cache_files(primed_storage: Path) -> Path: + """YAML + StorageJSON + cache, all consistent and fresh.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") + _set_cache_mtime(cache, primed_storage, offset=5) + return primed_storage + + def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None: """The cache file shape is predictable from the YAML filename.""" path = compiled_config_path("device.yaml") - assert path.name == "device.yaml.validated.yaml" + assert path.name == "device.yaml.validated.json" assert path.parent.name == "storage" @@ -126,9 +131,8 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None: assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q=" assert config["ota"][0]["password"] == "secret" - # The fast path loads without per-node source ranges (the full - # contract lives in test_yaml_util; this checks the flag is wired up). - assert not isinstance(config[CONF_ESPHOME][CONF_NAME], ESPHomeDataBase) + # The fast path loads plain scalars; no per-node source ranges exist. + assert type(config[CONF_ESPHOME][CONF_NAME]) is str # apply_to_core populated exactly what upload/logs read off CORE. assert CORE.name == "lite_test" @@ -147,7 +151,7 @@ def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None: storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -168,7 +172,7 @@ def test_load_compiled_config_skips_esp32_block_for_other_platforms( esp_platform="ESP8266", core_platform="esp8266", ) - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=5) assert load_compiled_config(yaml_path) is not None @@ -185,7 +189,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path storage_dir = tmp_path / ".esphome" / "storage" - cache_path = storage_dir / "lite_test.yaml.validated.yaml" + cache_path = storage_dir / "lite_test.yaml.validated.json" sidecar_path = storage_dir / "lite_test.yaml.json" if scenario == "missing_cache": @@ -196,7 +200,7 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: elif scenario == "corrupt_cache": _write_storage(sidecar_path) _set_cache_mtime( - _write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5 + _write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5 ) elif scenario == "missing_sidecar": # Cache fresh + parseable, but no StorageJSON → can't populate CORE. @@ -205,6 +209,108 @@ def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None: assert load_compiled_config(yaml_path) is None +@pytest.mark.parametrize( + "body", + [ + pytest.param( + json.dumps( + {"v": 999, "esphome": const.__version__, "config": {"esphome": {}}} + ), + id="wrong_version", + ), + pytest.param( + json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}), + id="missing_version", + ), + pytest.param( + json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}), + id="other_esphome_version", + ), + pytest.param( + json.dumps({"v": 1, "config": {"esphome": {}}}), + id="missing_esphome_version", + ), + pytest.param( + json.dumps( + { + "v": 1, + "esphome": const.__version__, + "config": ["not", "a", "dict"], + } + ), + id="non_dict_config", + ), + pytest.param( + json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config" + ), + pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"), + ], +) +def test_load_compiled_config_rejects_bad_envelope( + primed_storage: Path, body: str +) -> None: + """A foreign or future cache shape falls back instead of half-loading.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body) + _set_cache_mtime(cache, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None: + """A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + legacy = _write_cache( + storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n" + ) + _set_cache_mtime(legacy, primed_storage, offset=5) + + assert load_compiled_config(primed_storage) is None + + +def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None: + """A successful save leaves only the JSON cache behind.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert compiled_config_path("lite_test.yaml").is_file() + assert not legacy.exists() + + +def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None: + """The secret-bearing legacy cache goes away regardless of write outcome.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text("esphome:\n name: lite_test\n") + + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert not legacy.exists() + assert not compiled_config_path("lite_test.yaml").exists() + + +def test_save_warns_when_legacy_cache_unremovable( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A secret-bearing legacy file that won't unlink warns; the write proceeds.""" + CORE.config_path = tmp_path / "lite_test.yaml" + legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.mkdir() # unlink() on a directory raises OSError + + with caplog.at_level("WARNING", logger="esphome.compiled_config"): + save_compiled_config({"esphome": {"name": "lite_test"}}) + + assert "legacy validated-config cache" in caplog.text + assert compiled_config_path("lite_test.yaml").is_file() + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_use_cache_when_fresh( command: str, @@ -258,7 +364,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( ) -> None: """Without a StorageJSON sidecar (no compile has run), the fallback skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) YAML would be inert and + so writing the rendered (secret-resolved) config would be inert and leak secrets to disk for nothing.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") @@ -293,7 +399,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( storage_dir = tmp_path / ".esphome" / "storage" _write_storage(storage_dir / "lite_test.yaml.json") - cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache, yaml_path, offset=-60) # stale fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} @@ -386,28 +492,161 @@ def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None def test_save_compiled_config_writes_cache(tmp_path: Path) -> None: - """`save_compiled_config` writes the dumped YAML next to the sidecar.""" + """`save_compiled_config` writes the JSON envelope next to the sidecar.""" CORE.config_path = tmp_path / "lite_test.yaml" save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}}) cache_path = compiled_config_path("lite_test.yaml") assert cache_path.is_file() - body = cache_path.read_text() - assert "name: lite_test" in body - assert "logger:" in body + envelope = json.loads(cache_path.read_text()) + assert envelope["v"] == 1 + assert envelope["esphome"] == const.__version__ + assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}} -def test_save_compiled_config_swallows_dump_errors( +def test_save_compiled_config_swallows_write_errors( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Failures during the dump are non-fatal -- a bad cache just means + """Failures during the write are non-fatal -- a bad cache just means the next fast path falls back to read_config().""" CORE.config_path = tmp_path / "lite_test.yaml" - with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")): + with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")): save_compiled_config({"esphome": {"name": "lite_test"}}) assert not compiled_config_path("lite_test.yaml").exists() +def test_save_stringifies_unknown_values(tmp_path: Path) -> None: + """A type with no dedicated encoding stores its string form.""" + + class Weird: + def __str__(self) -> str: + return "weird-str" + + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}}) + envelope = json.loads(compiled_config_path("lite_test.yaml").read_text()) + assert envelope["config"]["esphome"]["weird"] == "weird-str" + + +def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None: + """A non-basic dict key aborts the write; the fast path falls back.""" + CORE.config_path = tmp_path / "lite_test.yaml" + save_compiled_config({"esphome": {("a", "b"): "lite_test"}}) + assert not compiled_config_path("lite_test.yaml").exists() + + +def _normalize(value: Any) -> Any: + """Make Lambda comparable; everything else compares by value already.""" + if isinstance(value, Lambda): + return ("__lambda__", value.value) + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize(v) for v in value] + return value + + +def _round_trip_config() -> OrderedDict: + """A post-validation shaped config exercising every representer type.""" + return OrderedDict( + { + "esphome": OrderedDict( + { + "name": "lite_test", + "build_path": Path("/build/lite_test"), + "on_boot": [ + OrderedDict( + { + "trigger_id": ID("trigger_1", type="Trigger"), + "then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}], + } + ) + ], + } + ), + "wifi": OrderedDict( + { + "id": ID("wifi_id", type="WiFiComponent"), + "reboot_timeout": TimePeriodMilliseconds(milliseconds=900000), + "use_address": IPv4Address("192.168.1.42"), + "subnet": IPv4Network("192.168.1.0/24"), + "mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01), + } + ), + "misc": OrderedDict( + { + "uuid": UUID("12345678-1234-5678-1234-567812345678"), + "toolchain": Toolchain.PLATFORMIO, + "hex": HexInt(0x1234), + "levels": (1, 2.5, True, None), + "empty": {}, + } + ), + } + ) + + +def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None: + """The JSON cache loads the same tree the YAML cache used to.""" + config = _round_trip_config() + save_compiled_config(config) + from_json = load_compiled_config(primed_storage) + assert from_json is not None + + yaml_cache = primed_storage.parent / "dumped.yaml" + yaml_cache.write_text(yaml_util.dump(config, show_secrets=True)) + from_yaml = yaml_util.load_yaml( + yaml_cache, clear_secrets=False, track_document_range=False + ) + + assert _normalize(from_json) == _normalize(from_yaml) + + +def test_lambda_sentinel_round_trips(primed_storage: Path) -> None: + """A !lambda body comes back as a Lambda with the same source.""" + body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";' + save_compiled_config( + { + "esphome": {"name": "lite_test"}, + "script": [{"then": [{"lambda": Lambda(body)}]}], + } + ) + + config = load_compiled_config(primed_storage) + assert config is not None + revived = config["script"][0]["then"][0]["lambda"] + assert isinstance(revived, Lambda) + assert revived.value == body + + +def test_object_hook_requires_exact_shape(primed_storage: Path) -> None: + """Only the exact one-key string-valued sentinel revives a Lambda.""" + storage_dir = primed_storage.parent / ".esphome" / "storage" + config = { + "esphome": {"name": "lite_test"}, + "extra_key": {_LAMBDA_KEY: "x", "y": 1}, + "non_str": {_LAMBDA_KEY: 5}, + } + cache = _write_cache( + storage_dir / "lite_test.yaml.validated.json", _cache_body(config) + ) + _set_cache_mtime(cache, primed_storage, offset=5) + + loaded = load_compiled_config(primed_storage) + assert loaded is not None + assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1} + assert loaded["non_str"] == {_LAMBDA_KEY: 5} + + +def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: + """Non-str basic keys stringify; validated configs only use string keys.""" + save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}}) + + config = load_compiled_config(primed_storage) + assert config is not None + assert config["table"] == {"1": "a", "2": "b"} + + def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: """A wizard-only sidecar (no compile -- no core_platform / target_platform) can't drive upload/logs, so the fast path falls back.""" @@ -426,7 +665,7 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> Non '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' '"framework": null, "core_platform": null}' ) - cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml") + cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) assert load_compiled_config(yaml_path) is None diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0cb0c1f62d..7f00d00ef7 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,5 +1,7 @@ import os from pathlib import Path +import subprocess +import sys from unittest.mock import patch from hypothesis import given @@ -213,6 +215,31 @@ class TestLambda: assert str(target) is value.value + def test_init__expression_initializer(self): + from esphome.cpp_generator import RawExpression + + target = core.Lambda(RawExpression("foo()")) + + assert target.value == "foo();" + + def test_init__other_initializer(self): + target = core.Lambda(123) + + assert target.value == 123 + + def test_init_from_str_does_not_import_codegen(self): + """The validated-config cache revives Lambdas on the upload fast path.""" + # sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it. + check = ( + "import sys; from esphome.core import Lambda; " + "Lambda('return 1;'); " + "sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)" + ) + result = subprocess.run( + [sys.executable, "-c", check], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + def test_parts(self): target = core.Lambda(SAMPLE_LAMBDA.strip()) diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 8358f4b781..2e09c4a945 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -46,6 +46,11 @@ API_HEAVY_MODULES = ("aioesphomeapi",) # never pays for the bundle machinery and its tarfile chain. BUNDLE_HEAVY_MODULES = ("esphome.bundle", "tarfile") +# Heavy only for a cache-hit upload/logs run: the JSON cache parse must +# not resolve pyyaml or the yaml_util chain (the read_config fallback +# still uses both). +CACHE_HIT_HEAVY_MODULES = ("esphome.yaml_util", "yaml") + # Stdlib modules deferred out of the dispatch fast path: a cache-hit # upload/logs run never writes a file (tempfile), spawns a process # (subprocess), parses a URL (urllib.parse), or prints a serial @@ -56,8 +61,6 @@ STDLIB_FAST_PATH_MODULES = ( "tempfile", "subprocess", "getpass", - # Pins the module-level contract only: PyYAML's constructor loads - # datetime during the cache parse until the JSON cache lands. "datetime", *(("urllib.parse",) if sys.version_info >= (3, 13) else ()), ) @@ -108,6 +111,7 @@ def test_watched_heavy_modules_exist() -> None: FAST_PATH_HEAVY_MODULES + API_HEAVY_MODULES + BUNDLE_HEAVY_MODULES + + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES ): assert importlib.util.find_spec(module) is not None, ( @@ -270,13 +274,13 @@ def test_upload_command_path_does_not_import_heavy_modules( leaked = _leaked_from_fixture( fixture_path, "upload_command_fast_path.py", - extra=BUNDLE_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, + extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) assert not leaked, ( f"the upload dispatch path pulls in heavy modules: {leaked}. " "An ordinary run only needs the bundle suffix constant, and the " - "cache parse must not resolve voluptuous; keep the esphome.bundle " - "import inside the branch that extracts one, the Invalid import " - "inside the branch that raises it, and the deferred stdlib " - "imports inside the write/spawn/serial helpers that use them." + "JSON cache parse must not resolve voluptuous or pyyaml; keep the " + "esphome.bundle import inside the branch that extracts one, the " + "yaml_util imports inside the read_config fallback, and the " + "deferred stdlib imports inside the write/spawn/serial helpers." ) From 25cb4400059e6d8dca4b5cfb5049830d6a815c31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 00:29:00 -0500 Subject: [PATCH 047/597] [core] Use Happy Eyeballs for remote file downloads (#18050) --- .../components/dashboard_import/__init__.py | 2 + esphome/components/esp32/__init__.py | 14 +- esphome/components/font/__init__.py | 2 + esphome/components/shelly_dimmer/light.py | 2 + esphome/external_files.py | 4 + esphome/framework_helpers.py | 5 + esphome/happy_eyeballs.py | 136 ++++++++ requirements.txt | 1 + tests/unit_tests/test_happy_eyeballs.py | 325 ++++++++++++++++++ 9 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 esphome/happy_eyeballs.py create mode 100644 tests/unit_tests/test_happy_eyeballs.py diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 000db307b9..31559a514c 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -12,6 +12,7 @@ from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -109,6 +110,7 @@ def import_config( if git_file.query and "full_config" in git_file.query: url = git_file.raw_url try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d16e8ae03c..2e72c78974 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3286,9 +3286,19 @@ def copy_files(): if str(path).startswith("http"): import requests + from esphome.happy_eyeballs import ensure_happy_eyeballs + + ensure_happy_eyeballs() + + try: + req = requests.get(path, timeout=30) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise EsphomeError( + f"Could not download extra build file {path}: {e}" + ) from e CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - content = requests.get(path, timeout=30).content - CORE.relative_build_path(name).write_bytes(content) + CORE.relative_build_path(name).write_bytes(req.content) else: copy_file_if_changed(path, CORE.relative_build_path(name)) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7510f2f8b6..5872b607f1 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -36,6 +36,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -319,6 +320,7 @@ def download_gfont(value): if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) try: + ensure_happy_eyeballs() req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index f2ab5a4bc1..cd6d858067 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_WATT, ) from esphome.core import CORE, HexInt +from esphome.happy_eyeballs import ensure_happy_eyeballs DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -81,6 +82,7 @@ def get_firmware(value): def dl(url): try: + ensure_happy_eyeballs() req = requests.get(url, timeout=30) req.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/esphome/external_files.py b/esphome/external_files.py index 69423d3999..160a2b6c29 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -14,6 +14,7 @@ import requests import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import write_file from esphome.types import ConfigType @@ -92,6 +93,7 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: @@ -158,6 +160,7 @@ def compute_local_file_dir(domain: str) -> Path: def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) return path.read_bytes() @@ -231,6 +234,7 @@ def download_content_many( seen: dict[Path, str] = {path: url for url, path in items} if not seen: return + ensure_happy_eyeballs() _LOGGER.info("Checking %d %s for updates", len(seen), description) if len(seen) == 1: path, url = next(iter(seen.items())) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 202d4a2bfb..6ed608b171 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -13,6 +13,7 @@ import sys import time from typing import IO, TYPE_CHECKING +from esphome.happy_eyeballs import ensure_happy_eyeballs from esphome.helpers import ProgressBar, rmtree if TYPE_CHECKING: @@ -755,6 +756,8 @@ def download_with_resume( from esphome.core import EsphomeError + ensure_happy_eyeballs() + dest = Path(dest) part = dest.with_name(dest.name + ".part") meta = part.with_name(part.name + ".meta") @@ -922,6 +925,8 @@ def download_from_mirrors( from esphome.core import EsphomeError + ensure_happy_eyeballs() + # 1. Classify the target: filesystem path or open file object path_target: Path | None = None f: IO[bytes] | None = None diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py new file mode 100644 index 0000000000..ebfb94f1f9 --- /dev/null +++ b/esphome/happy_eyeballs.py @@ -0,0 +1,136 @@ +"""Happy Eyeballs (RFC 8305) connection support for requests/urllib3. + +urllib3 tries each resolved address in sequence with the full connect +timeout, so a network advertising IPv6 DNS without IPv6 connectivity stalls +every download for the whole timeout before IPv4 is tried. +``ensure_happy_eyeballs()`` swaps urllib3's ``create_connection`` for one +that races address families with a short stagger via aiohappyeyeballs, run +on a daemon-thread event loop so callers stay synchronous. +""" + +from __future__ import annotations + +import logging +import socket +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +# RFC 8305 recommended delay between staggered connection attempts. +HAPPY_EYEBALLS_DELAY = 0.25 + +# Extra seconds the connect thread gets beyond the connect timeout before +# the caller gives up waiting for it. +_THREAD_WAIT_BUFFER = 5.0 + + +def ensure_happy_eyeballs() -> None: + """Make urllib3 (and therefore requests) connect with Happy Eyeballs. + + Idempotent; call before performing requests-based downloads. + """ + stock: Callable[..., socket.socket] | None = None + try: + import urllib3.util.connection + + stock = urllib3.util.connection.create_connection + if getattr(stock, "_esphome_patched", False): + return + + urllib3.util.connection.create_connection = _make_create_connection() + except (ImportError, AttributeError) as err: # urllib3 internals moved + # WARNING: degraded mode brings back the stalls this module prevents. + _LOGGER.warning( + "Happy Eyeballs unavailable (%s); downloads use the slower stock " + "urllib3 connect", + err, + ) + _LOGGER.debug("Happy Eyeballs fallback traceback", exc_info=True) + if stock is not None: + # Latch so the warning fires once, not per download. + stock._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + + +def _make_create_connection() -> Callable[..., socket.socket]: + """Build a drop-in replacement for urllib3's ``create_connection``.""" + # Deferred so runs that never download skip the ~30 ms asyncio import. + import asyncio + + from aiohappyeyeballs import start_connection + from urllib3.exceptions import LocationParseError + from urllib3.util.connection import ( # noqa: PLC2701 + _set_socket_options, + allowed_gai_family, + ) + from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 + + from esphome import async_thread + + def create_connection( + address: tuple[str, int], + timeout: Any = _DEFAULT_TIMEOUT, + source_address: tuple[str, int] | None = None, + socket_options: Any = None, + ) -> socket.socket: + host, port = address + if host.startswith("["): + host = host.strip("[]") + try: + host.encode("idna") + except UnicodeError: + raise LocationParseError(f"'{host}', label empty or too long") from None + + addr_infos = socket.getaddrinfo( + host, port, allowed_gai_family(), socket.SOCK_STREAM + ) + if not addr_infos: + # Same error as stock urllib3. + raise OSError("getaddrinfo returns an empty list") + connect_timeout = ( + socket.getdefaulttimeout() if timeout is _DEFAULT_TIMEOUT else timeout + ) + + def socket_factory(addr_info: Any) -> socket.socket: + family, type_, proto, _, _ = addr_info + sock = socket.socket(family, type_, proto) + try: + _set_socket_options(sock, socket_options) + if source_address: + sock.bind(source_address) + except BaseException: + sock.close() + raise + return sock + + async def connect() -> socket.socket: + return await asyncio.wait_for( + start_connection( + addr_infos, + happy_eyeballs_delay=HAPPY_EYEBALLS_DELAY, + interleave=1, + socket_factory=socket_factory, + ), + connect_timeout, + ) + + wait = ( + None if connect_timeout is None else connect_timeout + _THREAD_WAIT_BUFFER + ) + # on_orphan closes a socket won after the timeout so it cannot leak. + sock = async_thread.run_async( + connect, timeout=wait, on_orphan=socket.socket.close + ) + # aiohappyeyeballs leaves the winning socket non-blocking; restore the + # blocking-with-timeout behavior urllib3 callers expect. + try: + sock.settimeout(connect_timeout) + except BaseException: + sock.close() + raise + return sock + + create_connection._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + return create_connection diff --git a/requirements.txt b/requirements.txt index d56a8daec1..4501b733a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ platformio==6.1.19 esptool==5.3.1 click==8.3.3 aioesphomeapi==45.7.0 +aiohappyeyeballs==2.6.2 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import diff --git a/tests/unit_tests/test_happy_eyeballs.py b/tests/unit_tests/test_happy_eyeballs.py new file mode 100644 index 0000000000..3335a8a3e3 --- /dev/null +++ b/tests/unit_tests/test_happy_eyeballs.py @@ -0,0 +1,325 @@ +"""Tests for the Happy Eyeballs urllib3 shim.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +import socket +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.happy_eyeballs import _make_create_connection, ensure_happy_eyeballs + + +def _addr_info(host: str, port: int) -> tuple[Any, ...]: + """Build a getaddrinfo-style result tuple for an IPv4 address.""" + return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, port)) + + +@pytest.fixture +def create_connection() -> Any: + """A freshly built Happy Eyeballs create_connection replacement.""" + return _make_create_connection() + + +@pytest.fixture +def listener() -> Generator[tuple[str, int]]: + """A listening TCP socket on localhost; yields its address.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(5) + yield server.getsockname() + server.close() + + +@pytest.fixture +def mock_gai(listener: tuple[str, int]) -> Generator[Any]: + """Resolve every host to two copies of the listener's address.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)] * 2) as mock: + yield mock + + +def test_ensure_happy_eyeballs_patches_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shim replaces urllib3's create_connection exactly once.""" + import urllib3.util.connection + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + + ensure_happy_eyeballs() + patched = urllib3.util.connection.create_connection + assert patched is not stock + assert patched._esphome_patched + + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is patched + + +def test_connects_and_restores_socket_state( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The winning socket comes back blocking, with timeout and options set.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)], + ) + + try: + assert sock.getpeername() == listener + assert sock.gettimeout() == 5 + assert sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0 + finally: + sock.close() + + +def test_single_address_connects( + create_connection: Any, listener: tuple[str, int] +) -> None: + """A host resolving to one address connects through the same path.""" + with patch("socket.getaddrinfo", return_value=[_addr_info(*listener)]): + sock = create_connection(("example.com", listener[1]), timeout=5) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_falls_back_to_working_address( + create_connection: Any, listener: tuple[str, int], monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreachable first address does not block the working one.""" + from esphome import happy_eyeballs + + # 192.0.2.1 (TEST-NET-1) blackholes or fails fast depending on the + # network; either way the second address must win well within the + # timeout instead of waiting out the first. A short stagger keeps the + # test's duration network independent. + monkeypatch.setattr(happy_eyeballs, "HAPPY_EYEBALLS_DELAY", 0.01) + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info(*listener)] + + with patch("socket.getaddrinfo", return_value=addr_infos): + sock = create_connection(("example.com", listener[1]), timeout=10) + + try: + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_bracketed_ipv6_host_is_stripped( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """A bracketed IPv6 literal is unbracketed before resolution.""" + sock = create_connection(("[::1]", listener[1]), timeout=5) + + try: + assert mock_gai.call_args[0][0] == "::1" + assert sock.getpeername() == listener + finally: + sock.close() + + +def test_source_address_is_bound( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """The socket binds to the requested source address before connecting.""" + sock = create_connection( + ("example.com", listener[1]), + timeout=5, + source_address=("127.0.0.1", 0), + ) + + try: + assert sock.getsockname()[0] == "127.0.0.1" + finally: + sock.close() + + +def test_socket_factory_failure_closes_socket( + listener: tuple[str, int], mock_gai: Any +) -> None: + """A socket-option failure fails the connect instead of leaking sockets. + + Instrumented at ``_set_socket_options`` (which the factory calls with + the just-created socket) rather than by patching ``socket.socket``, + which is platform dependent: the event loop's internal socketpair use + differs between platforms. + """ + created: list[socket.socket] = [] + + def failing_set_options(sock: socket.socket, options: Any) -> None: + created.append(sock) + raise OSError("bad socket option") + + # Patch before building the closure; it binds _set_socket_options at + # creation time. + with patch("urllib3.util.connection._set_socket_options", new=failing_set_options): + create_connection = _make_create_connection() + with pytest.raises(OSError): + create_connection( + ("example.com", listener[1]), + timeout=5, + socket_options=[(999999, 999999, 1)], + ) + + assert created, "socket factory never ran" + assert all(sock.fileno() == -1 for sock in created), "socket leaked open" + + +def test_default_timeout_yields_blocking_socket( + create_connection: Any, listener: tuple[str, int], mock_gai: Any +) -> None: + """Without an explicit timeout the socket follows the global default.""" + sock = create_connection(("example.com", listener[1])) + + try: + assert sock.gettimeout() is socket.getdefaulttimeout() + finally: + sock.close() + + +def test_settimeout_failure_closes_socket( + create_connection: Any, mock_gai: Any +) -> None: + """A failure restoring socket state closes the winner instead of leaking.""" + bad_sock = Mock() + bad_sock.settimeout.side_effect = OSError("bad timeout") + + with ( + patch("esphome.async_thread.run_async", return_value=bad_sock), + pytest.raises(OSError, match="bad timeout"), + ): + create_connection(("example.com", 80), timeout=5) + + bad_sock.close.assert_called_once() + + +def test_connect_timeout_raises() -> None: + """A connect that never completes raises within the timeout.""" + + async def never(*args: Any, **kwargs: Any) -> None: + await asyncio.sleep(60) + + addr_infos = [_addr_info("192.0.2.1", 9), _addr_info("192.0.2.2", 9)] + + # Patch before building the closure; it binds start_connection at + # creation time. + with patch("aiohappyeyeballs.start_connection", new=never): + create_connection = _make_create_connection() + with ( + patch("socket.getaddrinfo", return_value=addr_infos), + pytest.raises(TimeoutError), + ): + create_connection(("example.com", 80), timeout=0.1) + + +def test_invalid_host_raises_location_parse_error(create_connection: Any) -> None: + """Hostnames urllib3 would reject are still rejected.""" + from urllib3.exceptions import LocationParseError + + with pytest.raises(LocationParseError): + create_connection(("a" * 300, 80)) + + +def test_empty_getaddrinfo_raises_oserror(create_connection: Any) -> None: + """An empty resolution matches stock urllib3's OSError, not ValueError.""" + with ( + patch("socket.getaddrinfo", return_value=[]), + pytest.raises(OSError, match="empty"), + ): + create_connection(("example.com", 80), timeout=5) + + +def test_ensure_falls_back_to_stock_when_internals_move( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """If urllib3 private names disappear, downloads keep the stock connect + and the warning is latched to fire once, not per download.""" + import urllib3.util.connection + + from esphome import happy_eyeballs + + def stock(*args: Any, **kwargs: Any) -> None: + pass + + factory = Mock(side_effect=ImportError("gone")) + monkeypatch.setattr(urllib3.util.connection, "create_connection", stock) + monkeypatch.setattr(happy_eyeballs, "_make_create_connection", factory) + + ensure_happy_eyeballs() + ensure_happy_eyeballs() + assert urllib3.util.connection.create_connection is stock + assert factory.call_count == 1 + assert caplog.text.count("Happy Eyeballs unavailable") == 1 + + +def test_ensure_survives_missing_urllib3( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """An unimportable urllib3 degrades with a warning instead of raising.""" + import sys + + with patch.dict(sys.modules, {"urllib3.util.connection": None}): + ensure_happy_eyeballs() + assert "Happy Eyeballs unavailable" in caplog.text + + +def test_requests_routes_through_shim(monkeypatch: pytest.MonkeyPatch) -> None: + """Patching urllib3's create_connection actually reroutes requests.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + import threading + + import requests + import urllib3.util.connection + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + host, port = server.server_address + + calls: list[Any] = [] + shim = _make_create_connection() + + def counting(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return shim(*args, **kwargs) + + counting._esphome_patched = True + monkeypatch.setattr(urllib3.util.connection, "create_connection", counting) + + real_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo(h: str, p: int, *args: Any, **kwargs: Any) -> Any: + if h == "shim-test.invalid": + return [_addr_info(host, port), _addr_info(host, port)] + return real_getaddrinfo(h, p, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.get(f"http://shim-test.invalid:{port}/", timeout=5) + assert resp.status_code == 200 + assert resp.content == b"ok" + assert calls, "requests did not go through the patched create_connection" + finally: + server.shutdown() + server.server_close() From b0a9bfd381262d292e5c4177813845391b93a4b2 Mon Sep 17 00:00:00 2001 From: Mustafa KURU Date: Mon, 10 Aug 2026 16:59:32 +0300 Subject: [PATCH 048/597] [ld6002b] Add area and zone configuration (5/5) (#17823) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ld6002b/__init__.py | 3 +- esphome/components/ld6002b/binary_sensor.py | 46 +- esphome/components/ld6002b/button/__init__.py | 40 +- esphome/components/ld6002b/const.py | 12 + esphome/components/ld6002b/ld6002b.cpp | 588 +++++++++++++++++- esphome/components/ld6002b/ld6002b.h | 176 +++++- esphome/components/ld6002b/number/__init__.py | 99 +++ esphome/components/ld6002b/select/__init__.py | 16 +- esphome/components/ld6002b/sensor.py | 90 ++- .../ld6002b/test_final_validate.py | 146 ++++- tests/components/ld6002b/common.yaml | 53 ++ 11 files changed, 1219 insertions(+), 50 deletions(-) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index af074fc7ea..99f2ead3bb 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -14,7 +15,7 @@ ld6002b_ns = cg.esphome_ns.namespace("ld6002b") LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) -def _validate_wakeup_options(config): +def _validate_wakeup_options(config: ConfigType) -> ConfigType: """Reject wake options that would silently do nothing. Runs before the schema so the defaults for the keys below have not been diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 319ace6f5d..63f7b40c23 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -4,24 +4,35 @@ import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY from . import LD6002BComponent -from .const import CONF_LD6002B_ID, MAX_TARGETS +from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS DEPENDENCIES = ["ld6002b"] -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ), - } -).extend( - { - cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_OCCUPANCY, - ) - for i in range(MAX_TARGETS) - } +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } + ) + .extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } + ) + .extend( + { + cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(AREA_COUNT) + } + ) ) @@ -36,3 +47,8 @@ async def to_code(config): if target_config := config.get(f"target_{i + 1}"): sens = await binary_sensor.new_binary_sensor(target_config) cg.add(hub.set_target_presence_binary_sensor(i, sens)) + + for i in range(AREA_COUNT): + if area_config := config.get(f"detection_area_{i}"): + sens = await binary_sensor.new_binary_sensor(area_config) + cg.add(hub.set_area_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 0046131b62..c327c331c6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -2,15 +2,21 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, CONF_ID, CONF_WAKEUP_PIN, ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_GET_AREAS, CONF_GET_DELAY, CONF_GET_INSTALLATION, CONF_GET_LOW_POWER_MODE, @@ -19,6 +25,7 @@ from ..const import ( CONF_GET_TRIGGER_SPEED, CONF_GET_Z_RANGE, CONF_LD6002B_ID, + CONF_RESET_DETECTION_AREA, CONF_RESET_UNATTENDED, CONF_WAKE, ) @@ -31,6 +38,21 @@ ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_APPLY_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_AREAS): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), cv.Optional(CONF_GET_DELAY): button.button_schema( LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), @@ -62,10 +84,21 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config): +def final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] + if config.get(CONF_APPLY_AREA): + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", + path=[CONF_APPLY_AREA], + ) + if config.get(CONF_WAKE): hub_path = full_config.get_path_for_id(hub_id) hub_config = full_config.get_config_for_path(hub_path[:-1]) @@ -81,6 +114,11 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, CONF_GET_DELAY: ButtonType.GET_DELAY, CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py index fac9f08015..b7c3f54a6f 100644 --- a/esphome/components/ld6002b/const.py +++ b/esphome/components/ld6002b/const.py @@ -1,6 +1,11 @@ +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" CONF_AUTO_WAKE = "auto_wake" +CONF_CLEAR_INTERFERENCE = "clear_interference" CONF_CLUSTER_ID = "cluster_id" CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" CONF_GET_DELAY = "get_delay" CONF_GET_INSTALLATION = "get_installation" CONF_GET_LOW_POWER_MODE = "get_low_power_mode" @@ -16,6 +21,7 @@ CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" CONF_OTA_VERSION = "ota_version" CONF_POINT_CLOUD = "point_cloud" CONF_POINT_COUNT = "point_count" +CONF_RESET_DETECTION_AREA = "reset_detection_area" CONF_RESET_UNATTENDED = "reset_unattended" CONF_TARGET_DISPLAY = "target_display" CONF_TRIGGER_SPEED = "trigger_speed" @@ -26,4 +32,10 @@ CONF_Z = "z" CONF_Z_MAX = "z_max" CONF_Z_MIN = "z_min" +KEY_X_MIN = "x_min" +KEY_X_MAX = "x_max" +KEY_Y_MIN = "y_min" +KEY_Y_MAX = "y_max" + +AREA_COUNT = 4 MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 25b3da174c..ca6b9b9552 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -15,12 +15,16 @@ static constexpr uint32_t SETUP_DELAY_MS = 100; // Command/message types static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; @@ -32,6 +36,10 @@ static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; // Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; static constexpr uint32_t CMD_GET_DELAY = 0x05; static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; @@ -55,13 +63,26 @@ static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; -static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; #ifdef ESPHOME_LOG_HAS_VERBOSE static const char *control_command_name(uint32_t command) { switch (command) { + case CMD_AUTO_INTERFERENCE: + return "auto_interference"; + case CMD_GET_AREAS: + return "get_areas"; + case CMD_CLEAR_INTERFERENCE: + return "clear_interference"; + case CMD_RESET_DETECTION_AREA: + return "reset_detection_area"; case CMD_GET_DELAY: return "get_delay"; case CMD_POINT_CLOUD_ON: @@ -115,6 +136,8 @@ static const char *frame_type_name(uint16_t type) { switch (type) { case TYPE_CONTROL: return "control"; + case TYPE_SET_AREA: + return "set_area"; case TYPE_SET_HOLD_DELAY: return "set_hold_delay"; case TYPE_SET_Z_RANGE: @@ -125,6 +148,12 @@ static const char *frame_type_name(uint16_t type) { return "report_target"; case TYPE_REPORT_POINT_CLOUD: return "report_point_cloud"; + case TYPE_REPORT_AREA_PRESENCE: + return "report_area_presence"; + case TYPE_REPORT_INTERFERENCE_AREAS: + return "report_interference_areas"; + case TYPE_REPORT_DETECTION_AREAS: + return "report_detection_areas"; case TYPE_REPORT_DELAY: return "report_delay"; case TYPE_REPORT_SENSITIVITY: @@ -150,6 +179,8 @@ static const char *frame_type_name(uint16_t type) { static bool is_expected_control_report(uint32_t command, uint16_t type) { switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; case CMD_GET_DELAY: return type == TYPE_REPORT_DELAY; case CMD_GET_SENSITIVITY: @@ -200,6 +231,10 @@ void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { data[3] = (value >> 24) & 0xFF; } +void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { + write_u32_le(data, static_cast(value)); +} + void LD6002BComponent::write_f32_le(uint8_t *data, float value) { uint32_t raw; std::memcpy(&raw, &value, sizeof(raw)); @@ -356,6 +391,37 @@ void LD6002BComponent::setup() { this->send_control_command_(CMD_GET_LOW_POWER); } + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->interference_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + if (!want_area_report) { + for (const auto &area : this->detection_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); this->init_version_pref_(); #ifdef USE_TEXT_SENSOR @@ -374,7 +440,7 @@ void LD6002BComponent::dump_config() { this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); if (this->wakeup_pin_ != nullptr) { LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); - ESP_LOGCONFIG(TAG, " Wake Pulse: %ums", this->wakeup_pulse_ms_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); @@ -386,12 +452,31 @@ void LD6002BComponent::dump_config() { LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); } + for (auto &area : this->interference_areas_) { + LOG_SENSOR(" ", "Interference Area X Min", area.x_min); + LOG_SENSOR(" ", "Interference Area X Max", area.x_max); + LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); + LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); + LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); + LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); + } + for (auto &area : this->detection_areas_) { + LOG_SENSOR(" ", "Detection Area X Min", area.x_min); + LOG_SENSOR(" ", "Detection Area X Max", area.x_max); + LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); + LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); + LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); + LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); + } #endif #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); for (uint8_t i = 0; i < MAX_TARGETS; i++) { LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } #endif #ifdef USE_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); @@ -402,6 +487,12 @@ void LD6002BComponent::dump_config() { LOG_NUMBER(" ", "Z Min", this->z_min_number_); LOG_NUMBER(" ", "Z Max", this->z_max_number_); LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); #endif #ifdef USE_SWITCH LOG_SWITCH(" ", "Low Power", this->low_power_switch_); @@ -412,6 +503,7 @@ void LD6002BComponent::dump_config() { LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); #endif } @@ -527,6 +619,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ } if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; // This settles one expected reply; the rest stay owed and become the debt for the next command. this->send_generation_++; this->stale_ack_type_ = type; @@ -536,6 +629,10 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->command_sent_ = false; this->last_send_ms_ = 0; this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } return; } @@ -557,6 +654,15 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ case TYPE_REPORT_POINT_CLOUD: this->handle_point_cloud_(data, len); break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(true, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(false, data, len); + break; case TYPE_REPORT_DELAY: this->handle_delay_report_(data, len); break; @@ -654,8 +760,9 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) this->target_presence_any_ = (reported > 0); #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -728,6 +835,84 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { #endif } +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(present); + } +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; + if (area.x_min != nullptr) + area.x_min->publish_state(x_min); + if (area.x_max != nullptr) + area.x_max->publish_state(x_max); + if (area.y_min != nullptr) + area.y_min->publish_state(y_min); + if (area.y_max != nullptr) + area.y_max->publish_state(y_max); + if (area.z_min != nullptr) + area.z_min->publish_state(z_min); + if (area.z_max != nullptr) + area.z_max->publish_state(z_max); +#endif + + AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + bool selected_interference = selected_id < AREA_COUNT; + uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); + if (selected_interference == interference && selected_index == i) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(interference); +} + void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; @@ -815,13 +1000,24 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { if (len < 1) return; -#ifdef USE_TEXT_SENSOR + // Zero is the unattended half of this transition. Read outside the text sensor's + // ifdef because the area sensors do not need one configured to have gone stale. const bool low_power = (data[0] == 0); +#ifdef USE_TEXT_SENSOR if (this->work_mode_text_sensor_ != nullptr) { this->work_mode_reported_ = true; this->publish_work_mode_(low_power); } #endif + // Protocol V1.2 section 2.1.17: this message is sent only on the transition + // between the unattended low-power mode and normal operation, so a zero is the + // module stating that nobody is in any area. Not while a target is still being + // tracked, though: the reset_unattended command is undocumented on whether it + // forces this report, and where two statements from the module disagree the live + // one wins. + if (low_power && !this->target_presence_any_) { + this->clear_area_presence_(); + } } void LD6002BComponent::update_work_mode_fallback_() { @@ -832,9 +1028,10 @@ void LD6002BComponent::update_work_mode_fallback_() { if (!this->low_power_reported_) { return; } - // Presence is only meaningful while the stream that maintains it runs; with it - // off there is nothing to weigh and low power alone decides. - const bool presence = this->target_display_enabled_ && this->target_presence_any_; + // Target presence is only meaningful while the stream that maintains it runs. + // Area presence keeps its own report, so it still counts with the target stream + // off and low power alone decides only when neither half has anything to say. + const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_; this->publish_work_mode_(this->low_power_enabled_ && !presence); #endif } @@ -857,6 +1054,16 @@ void LD6002BComponent::publish_work_mode_(bool low_power) { void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { if (number == nullptr) return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + return; + } const float min_value = number->traits.get_min_value(); const float max_value = number->traits.get_max_value(); // Outside the declared range the user cannot write the value back, so publish @@ -891,14 +1098,14 @@ void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) #endif } -void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { +bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { if (len > CMD_MAX_DATA_LEN) { ESP_LOGW(TAG, "Command data too large: %u", len); - return; + return false; } if (this->cmd_count_ >= CMD_QUEUE_SIZE) { ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); - return; + return false; } PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; @@ -911,6 +1118,7 @@ void LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_ this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; this->cmd_count_++; this->process_command_queue_(); + return true; } void LD6002BComponent::process_command_queue_() { @@ -945,6 +1153,18 @@ void LD6002BComponent::process_command_queue_() { } else { ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } // A reply may still be in flight for the attempt we just gave up on, so carry one over as // debt rather than clearing the ledger, or that late ACK would retire the successor. Only // one: reaching this point means nothing was answered at all, so the older attempts are @@ -1067,10 +1287,10 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->last_traffic_ms_ = now; } -void LD6002BComponent::send_control_command_(uint32_t command) { +bool LD6002BComponent::send_control_command_(uint32_t command) { uint8_t data[4]; write_u32_le(data, command); - this->queue_command_(TYPE_CONTROL, data, sizeof(data)); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } void LD6002BComponent::send_z_range_() { @@ -1090,6 +1310,64 @@ void LD6002BComponent::send_z_range_() { this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +void LD6002BComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + const bool interference = this->area_id_ < AREA_COUNT; + const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + void LD6002BComponent::wake_() { // A command's own pulse raises the pin and writes after it, so ride along instead of // claiming the flag: claiming it would send that command down the immediate-write path @@ -1124,6 +1402,30 @@ void LD6002BComponent::set_number_value(NumberType type, float value) { this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); break; } + case NumberType::AREA_X_MIN: + this->area_x_min_ = value; + this->area_edits_.x_min = value; + break; + case NumberType::AREA_X_MAX: + this->area_x_max_ = value; + this->area_edits_.x_max = value; + break; + case NumberType::AREA_Y_MIN: + this->area_y_min_ = value; + this->area_edits_.y_min = value; + break; + case NumberType::AREA_Y_MAX: + this->area_y_max_ = value; + this->area_edits_.y_max = value; + break; + case NumberType::AREA_Z_MIN: + this->area_z_min_ = value; + this->area_edits_.z_min = value; + break; + case NumberType::AREA_Z_MAX: + this->area_z_max_ = value; + this->area_edits_.z_max = value; + break; } } @@ -1154,9 +1456,179 @@ void LD6002BComponent::set_select_value(SelectType type, size_t index) { this->send_control_command_(CMD_INSTALL_SIDE); } break; + case SelectType::AREA_ID: + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + break; } } +void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD6002BComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_int32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const bool interference = this->pending_area_id_ < AREA_COUNT; + const uint8_t index = + interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_interference == interference) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD6002BComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD6002BComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + void LD6002BComponent::init_version_pref_() { #ifdef USE_TEXT_SENSOR if (this->ota_version_text_sensor_ == nullptr) { @@ -1211,6 +1683,74 @@ void LD6002BComponent::clear_target_slot_(uint8_t index) { } #endif +void LD6002BComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD6002BComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(false); + } + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif +} + void LD6002BComponent::clear_target_state_() { // Nothing corrects any of this until the stream comes back. The slot table goes // with it: slots key on cluster ids, which only track a person while reports are @@ -1242,8 +1782,9 @@ void LD6002BComponent::clear_target_state_() { if (this->target_presence_any_) { this->target_presence_any_ = false; #ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; if (this->presence_binary_sensor_ != nullptr) { - this->presence_binary_sensor_->publish_state(this->target_presence_any_); + this->presence_binary_sensor_->publish_state(presence); } #endif this->update_work_mode_fallback_(); @@ -1284,6 +1825,27 @@ void LD6002BComponent::set_switch_state(SwitchType type, bool state) { void LD6002BComponent::press_button(ButtonType type) { switch (type) { + case ButtonType::APPLY_AREA: + this->apply_area_config_(); + break; + case ButtonType::AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; case ButtonType::GET_DELAY: this->send_control_command_(CMD_GET_DELAY); break; diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 141f4ff027..bea3804312 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -31,6 +31,10 @@ namespace esphome::ld6002b { static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; // Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. @@ -41,12 +45,19 @@ enum class NumberType : uint8_t { Z_MIN, Z_MAX, LOW_POWER_SLEEP, + AREA_X_MIN, + AREA_X_MAX, + AREA_Y_MIN, + AREA_Y_MAX, + AREA_Z_MIN, + AREA_Z_MAX, }; enum class SelectType : uint8_t { SENSITIVITY, TRIGGER_SPEED, INSTALLATION_MODE, + AREA_ID, }; enum class SwitchType : uint8_t { @@ -56,6 +67,11 @@ enum class SwitchType : uint8_t { }; enum class ButtonType : uint8_t { + APPLY_AREA, + AUTO_INTERFERENCE, + GET_AREAS, + CLEAR_INTERFERENCE, + RESET_DETECTION_AREA, GET_DELAY, GET_SENSITIVITY, GET_TRIGGER_SPEED, @@ -76,8 +92,25 @@ struct TargetSensors { sensor::Sensor *cluster_id{nullptr}; }; +struct AreaSensors { + sensor::Sensor *x_min{nullptr}; + sensor::Sensor *x_max{nullptr}; + sensor::Sensor *y_min{nullptr}; + sensor::Sensor *y_max{nullptr}; + sensor::Sensor *z_min{nullptr}; + sensor::Sensor *z_max{nullptr}; +}; #endif +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + struct VersionPref { char value[20]; }; @@ -122,6 +155,67 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->targets_[target].cluster_id = sensor; } + void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_min = sensor; + } + void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_max = sensor; + } + void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_min = sensor; + } + void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_max = sensor; + } + void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_min = sensor; + } + void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_max = sensor; + } + + void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_min = sensor; + } + void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_max = sensor; + } + void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_min = sensor; + } + void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_max = sensor; + } + void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_min = sensor; + } + void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_max = sensor; + } #endif #ifdef USE_BINARY_SENSOR @@ -131,6 +225,11 @@ class LD6002BComponent : public Component, public uart::UARTDevice { return; this->target_presence_[target] = sensor; } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } #endif #ifdef USE_TEXT_SENSOR @@ -143,12 +242,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } + + void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; } + void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; } + void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; } + void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; } + void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; } + void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; } #endif #ifdef USE_SELECT void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } void set_installation_select(select::Select *select) { this->installation_select_ = select; } + void set_area_id_select(select::Select *select) { this->area_id_select_ = select; } #endif #ifdef USE_SWITCH @@ -176,6 +283,8 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); void handle_target_report_(const uint8_t *data, uint16_t len); void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(bool interference, const uint8_t *data, uint16_t len); void handle_delay_report_(const uint8_t *data, uint16_t len); void handle_sensitivity_report_(const uint8_t *data, uint16_t len); void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); @@ -189,22 +298,35 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void publish_work_mode_(bool low_power); // Drops every target-derived reading and the slot table they are indexed by. void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); #ifdef USE_SENSOR void clear_target_slot_(uint8_t index); #endif #ifdef USE_NUMBER void publish_number_clamped_(number::Number *number, float value); #endif + void update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(bool reported_interference); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); void init_version_pref_(); void save_version_pref_(const char *value); - void queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); void process_command_queue_(); void send_command_(uint16_t type, const uint8_t *data, uint8_t len); void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); - void send_control_command_(uint32_t command); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); void send_z_range_(); + void apply_area_config_(); void wake_(); static uint16_t read_u16_be(const uint8_t *data); @@ -212,16 +334,20 @@ class LD6002BComponent : public Component, public uart::UARTDevice { static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); + static void write_int32_le(uint8_t *data, int32_t value); static void write_f32_le(uint8_t *data, float value); #ifdef USE_SENSOR std::array targets_{}; sensor::Sensor *target_count_sensor_{nullptr}; sensor::Sensor *point_count_sensor_{nullptr}; + std::array interference_areas_{}; + std::array detection_areas_{}; #endif #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; std::array target_presence_{}; + std::array area_presence_{}; #endif #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; @@ -234,11 +360,21 @@ class LD6002BComponent : public Component, public uart::UARTDevice { number::Number *z_min_number_{nullptr}; number::Number *z_max_number_{nullptr}; number::Number *low_power_sleep_number_{nullptr}; + + number::Number *area_x_min_number_{nullptr}; + number::Number *area_x_max_number_{nullptr}; + number::Number *area_y_min_number_{nullptr}; + number::Number *area_y_max_number_{nullptr}; + number::Number *area_z_min_number_{nullptr}; + number::Number *area_z_max_number_{nullptr}; #endif #ifdef USE_SELECT select::Select *sensitivity_select_{nullptr}; select::Select *trigger_speed_select_{nullptr}; select::Select *installation_select_{nullptr}; + select::Select *area_id_select_{nullptr}; + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; #endif #ifdef USE_SWITCH switch_::Switch *low_power_switch_{nullptr}; @@ -264,9 +400,13 @@ class LD6002BComponent : public Component, public uart::UARTDevice { uint8_t *data_buf_{nullptr}; uint16_t next_frame_id_{0}; - // Sized for the boot burst: with every platform configured, setup() enqueues - // roughly ten GET/config commands back to back before the first ack lands. - static constexpr uint8_t CMD_QUEUE_SIZE = 16; + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; // A sleeping module consumes the first frame to wake and answers only the one after it. static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; @@ -276,6 +416,9 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // Named so a repeated press replaces its own pending timeout instead of stacking // another, and so the command path can cancel it when it takes the pin over. static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; + // Named so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; @@ -307,6 +450,24 @@ class LD6002BComponent : public Component, public uart::UARTDevice { float z_min_{NAN}; float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array interference_area_values_{}; + std::array detection_area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; // Which person owns each target_N slot, so a slot survives the module re-sorting its array. std::array slot_cluster_{}; @@ -318,9 +479,14 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // The report handlers read these and drop anything a stopped stream still emits. bool target_display_enabled_{false}; bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; bool work_mode_reported_{false}; bool low_power_enabled_{false}; bool low_power_reported_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; bool last_work_mode_valid_{false}; bool last_work_mode_low_power_{false}; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 10e9e89dc8..7e0be66c64 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, ENTITY_CATEGORY_CONFIG, @@ -9,14 +11,22 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_SECOND, ) +import esphome.final_validate as fv +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( + CONF_APPLY_AREA, + CONF_AREA_CONFIG, CONF_HOLD_DELAY, CONF_LD6002B_ID, CONF_LOW_POWER_SLEEP_TIME, CONF_Z_MAX, CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, ) DEPENDENCIES = ["ld6002b"] @@ -51,10 +61,83 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, entity_category=ENTITY_CATEGORY_CONFIG, ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + { + cv.Optional(KEY_X_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_X_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } + ), } ) +def final_validate(config: ConfigType) -> ConfigType: + if config.get(CONF_AREA_CONFIG) is None: + return config + + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + has_apply_area = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None + for entry in full_config.get(CONF_BUTTON, []) + ) + if not has_apply_area: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + return config + + +FINAL_VALIDATE_SCHEMA = final_validate + + async def to_code(config): hub = await cg.get_variable(config[CONF_LD6002B_ID]) @@ -80,3 +163,19 @@ async def to_code(config): ) await cg.register_parented(n, config[CONF_LD6002B_ID]) cg.add(getattr(hub, setter)(n)) + + if area_config := config.get(CONF_AREA_CONFIG): + for key, number_type, setter in ( + (KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"), + (KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"), + (KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"), + (KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"), + (CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"), + (CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"), + ): + if conf := area_config.get(key): + n = await number.new_number( + conf, number_type, min_value=-10, max_value=10, step=0.1 + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3fcc117e2f..3da647ee2c 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -11,6 +11,16 @@ DEPENDENCIES = ["ld6002b"] LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) SelectType = ld6002b_ns.enum("SelectType", is_class=True) +AREA_ID_OPTIONS = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", +] CONFIG_SCHEMA = cv.Schema( { @@ -24,6 +34,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG ), + cv.Optional(CONF_AREA_ID): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), } ) @@ -47,6 +60,7 @@ SELECT_MAP = ( "set_installation_select", ["top", "side"], ), + (CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS), ) diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index ff88d343b9..3aedaf9fdd 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -12,11 +12,18 @@ from esphome.const import ( from . import LD6002BComponent from .const import ( + AREA_COUNT, CONF_CLUSTER_ID, CONF_DOPPLER_INDEX, CONF_LD6002B_ID, CONF_POINT_COUNT, CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, MAX_TARGETS, ) @@ -68,20 +75,79 @@ TARGET_SCHEMA = cv.Schema( } ) - -CONFIG_SCHEMA = cv.Schema( +AREA_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), - cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( - accuracy_decimals=0, + cv.Optional(KEY_X_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, state_class=STATE_CLASS_MEASUREMENT, ), } -).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) +) + +# (config key, C++ setter axis) for the six bounds every area sensor block carries. +_AREA_AXES = ( + (KEY_X_MIN, "x_min"), + (KEY_X_MAX, "x_max"), + (KEY_Y_MIN, "y_min"), + (KEY_Y_MAX, "y_max"), + (CONF_Z_MIN, "z_min"), + (CONF_Z_MAX, "z_max"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + .extend( + {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) + .extend( + {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) +) async def to_code(config): @@ -112,3 +178,11 @@ async def to_code(config): if cluster_id_config := target_config.get(CONF_CLUSTER_ID): sens = await sensor.new_sensor(cluster_id_config) cg.add(hub.set_target_cluster_id_sensor(i, sens)) + + for kind in ("interference", "detection"): + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + for key, axis in _AREA_AXES: + if axis_config := area_config.get(key): + sens = await sensor.new_sensor(axis_config) + cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) diff --git a/tests/component_tests/ld6002b/test_final_validate.py b/tests/component_tests/ld6002b/test_final_validate.py index 49fa35eb13..0bb091533b 100644 --- a/tests/component_tests/ld6002b/test_final_validate.py +++ b/tests/component_tests/ld6002b/test_final_validate.py @@ -1,30 +1,62 @@ -"""Tests for the wake button's wakeup_pin requirement in ld6002b.""" +"""Tests for the ld6002b validators that reach across platforms. + +wake needs a pin on its own hub, apply_area needs a select on its own hub, and +area_config needs both a button and a select on its own hub. Every one of them +is a same-instance check, which is the half that breaks quietly. +""" from __future__ import annotations import pytest -from esphome.components.ld6002b.button import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.components.ld6002b.button import ( + CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA, +) +from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN +from esphome.components.ld6002b.number import ( + CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA, +) from esphome.config import Config import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN, PlatformFramework +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + CONF_ID, + CONF_WAKEUP_PIN, + PlatformFramework, +) from esphome.core import ID from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "ld6002b_hub" +OTHER_HUB_ID = "ld6002b_other" -def _full_config(hub: ConfigType) -> Config: +def _full_config( + hub: ConfigType, + *, + selects: list[ConfigType] | None = None, + buttons: list[ConfigType] | None = None, +) -> Config: """A full config carrying one ld6002b hub, as the ID pass leaves it. final_validate resolves the hub through get_path_for_id, so the declaring path has to be registered the way validate_config registers it: the path of the id value itself, whose parent is the hub's own config. + + The platform lists are what the cross-platform validators scan, so a test can + say which of them exist and which hub each one names. """ full = Config() full["ld6002b"] = [hub] full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID])) + if selects is not None: + full["select"] = selects + if buttons is not None: + full[CONF_BUTTON] = buttons return full @@ -44,10 +76,33 @@ def _buttons(**buttons: str) -> ConfigType: return config +def _select(*, hub_id: str = HUB_ID) -> ConfigType: + """A select platform config naming area_id on the given hub.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_ID: {"name": "Area ID"}, + } + + +def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType: + """A number platform config carrying one area_config bound.""" + return { + "ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"), + CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}}, + } + + def _validated(config: ConfigType) -> ConfigType: """Run the button schema, then the final validation the hub is checked in.""" - config = CONFIG_SCHEMA(config) - FINAL_VALIDATE_SCHEMA(config) + config = BUTTON_CONFIG_SCHEMA(config) + BUTTON_FINAL_VALIDATE_SCHEMA(config) + return config + + +def _validated_numbers(config: ConfigType) -> ConfigType: + """The same two passes for the number platform.""" + config = NUMBER_CONFIG_SCHEMA(config) + NUMBER_FINAL_VALIDATE_SCHEMA(config) return config @@ -80,3 +135,82 @@ def test_other_buttons_do_not_need_the_pin( ) _validated(_buttons(get_delay="Get Delay")) + + +def test_apply_area_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """apply_area sends the staged bounds to whichever area the select names.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False)) + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_apply_area_select_on_another_hub_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A select exists, but on a second ld6002b -- which cannot serve this one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^apply_area requires select\.area_id for the same ld6002b instance" + r" @ data\['apply_area'\]$" + ), + ): + _validated(_buttons(apply_area="Apply Area")) + + +def test_area_config_without_apply_area_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The six numbers only stage a write; apply_area is what sends it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires button\.apply_area for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) + + +def test_area_config_without_select_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The validator's other half: the staged bounds also need an area to land in.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config=_full_config( + _hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")] + ), + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^area_config requires select\.area_id for the same ld6002b instance" + r" @ data\['area_config'\]$" + ), + ): + _validated_numbers(_area_numbers()) diff --git a/tests/components/ld6002b/common.yaml b/tests/components/ld6002b/common.yaml index e31af49aec..ee881bd787 100644 --- a/tests/components/ld6002b/common.yaml +++ b/tests/components/ld6002b/common.yaml @@ -42,6 +42,32 @@ sensor: name: Target-3 Dop cluster_id: name: Target-3 Cluster + interference_area_0: + x_min: + name: Interference-0 X Min + x_max: + name: Interference-0 X Max + y_min: + name: Interference-0 Y Min + y_max: + name: Interference-0 Y Max + z_min: + name: Interference-0 Z Min + z_max: + name: Interference-0 Z Max + detection_area_0: + x_min: + name: Detection-0 X Min + x_max: + name: Detection-0 X Max + y_min: + name: Detection-0 Y Min + y_max: + name: Detection-0 Y Max + z_min: + name: Detection-0 Z Min + z_max: + name: Detection-0 Z Max binary_sensor: - platform: ld6002b @@ -50,6 +76,8 @@ binary_sensor: name: Presence target_1: name: Target-1 Presence + detection_area_0: + name: Detection Area-0 Presence text_sensor: - platform: ld6002b @@ -70,6 +98,19 @@ number: name: Z Max low_power_sleep_time: name: Low Power Sleep + area_config: + x_min: + name: Area X Min + x_max: + name: Area X Max + y_min: + name: Area Y Min + y_max: + name: Area Y Max + z_min: + name: Area Z Min + z_max: + name: Area Z Max select: - platform: ld6002b @@ -80,6 +121,8 @@ select: name: Trigger Speed installation_mode: name: Installation + area_id: + name: Area ID switch: - platform: ld6002b @@ -94,6 +137,16 @@ switch: button: - platform: ld6002b ld6002b_id: ld6002b_radar + apply_area: + name: Apply Area + auto_interference: + name: Auto Interference + get_areas: + name: Get Areas + clear_interference: + name: Clear Interference + reset_detection_area: + name: Reset Detection get_delay: name: Get Delay get_sensitivity: From 3656375516e08022f19e1e7b2467274da113819a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 10 Aug 2026 10:00:29 -0400 Subject: [PATCH 049/597] [sendspin] Bump sendspin-cpp to v0.7.1 (#18232) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e20925f323..d0c2112ba9 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b4b20cf221..9448b93cc9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.0 + version: 0.7.1 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 02fa18b74fb0a319f3858ed10dd145e270c02c25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 09:44:01 -0500 Subject: [PATCH 050/597] [core] Skip colorama init for terminal and dashboard runs (#18224) --- esphome/__main__.py | 20 +- esphome/log.py | 23 +- tests/unit_tests/conftest.py | 14 + .../fixtures/log/setup_log_probe.py | 21 ++ tests/unit_tests/test_lazy_imports.py | 24 +- tests/unit_tests/test_log.py | 267 +++++++++++++++++- 6 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 tests/unit_tests/fixtures/log/setup_log_probe.py diff --git a/esphome/__main__.py b/esphome/__main__.py index cb45dd7c5f..cc1e12cb3a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1941,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{c}' is an invalid character for names. Valid characters are: " @@ -1954,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - print( + safe_print( color( AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." ) @@ -2001,7 +2001,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) > 1 ): - print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")) + safe_print( + color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") + ) return 1 new_raw = re.sub( @@ -2019,7 +2021,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # would otherwise just re-flash the same hostname). if new_name == old_name: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2029,7 +2031,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path: Path = CORE.config_dir / (new_name + ".yaml") if new_path.resolve() == CORE.config_path.resolve(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -2037,7 +2039,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 if new_path.exists(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"Cannot rename: {new_path} already exists. " @@ -2045,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) ) return 1 - print( + safe_print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() @@ -2054,7 +2056,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: - print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) + safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() return 1 @@ -2080,7 +2082,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if CORE.config_path != new_path: CORE.config_path.unlink() - print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() return 0 diff --git a/esphome/log.py b/esphome/log.py index b120c930d0..1f208bb909 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -1,5 +1,7 @@ from enum import Enum import logging +import sys +from typing import TextIO from esphome.core import CORE @@ -72,13 +74,30 @@ class ESPHomeLogFormatter(logging.Formatter): return message +def _is_tty(stream: TextIO | None) -> bool: + # A stream can be missing, closed, or not a real file object; colorama + # tolerates all three, so treat them like a redirect and let its own + # handling apply. + if stream is None or getattr(stream, "closed", True): + return False + return hasattr(stream, "isatty") and stream.isatty() + + def setup_log( log_level: int = logging.INFO, include_timestamp: bool = False, ) -> None: - import colorama + # colorama translates ANSI escapes for old Windows consoles and strips + # them from redirected output. POSIX terminals render ANSI natively, and + # dashboard runs escape their color codes before printing, so both would + # use colorama as a plain passthrough; skip the import there (it pulls + # in ctypes, ~3ms on every CLI invocation). + if sys.platform == "win32" or not ( + CORE.dashboard or (_is_tty(sys.stdout) and _is_tty(sys.stderr)) + ): + import colorama - colorama.init() + colorama.init() # Setup logging - will map log level from string to constant logging.basicConfig(level=log_level) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 13450b10f0..9de8f715ef 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -10,6 +10,7 @@ not be part of a unit test suite. """ from collections.abc import Generator +import os from pathlib import Path import sys from unittest.mock import Mock, patch @@ -40,6 +41,19 @@ def fixture_path() -> Path: return here / "fixtures" +@pytest.fixture +def probe_env() -> dict[str, str]: + """Environment for running fixture probe scripts as subprocesses. + + Running a script file drops the cwd from sys.path, so prepend the + repo root for the child. + """ + python_path = str(package_root) + if ambient := os.environ.get("PYTHONPATH"): + python_path = os.pathsep.join((python_path, ambient)) + return os.environ | {"PYTHONPATH": python_path} + + @pytest.fixture def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" diff --git a/tests/unit_tests/fixtures/log/setup_log_probe.py b/tests/unit_tests/fixtures/log/setup_log_probe.py new file mode 100644 index 0000000000..b9e2e02a8c --- /dev/null +++ b/tests/unit_tests/fixtures/log/setup_log_probe.py @@ -0,0 +1,21 @@ +"""Report whether setup_log() pulled in colorama, then print a colored line. + +Executed as a subprocess by test_log.py because module imports are +process-global: the parent prints ``colorama_loaded=True/False`` plus an +ANSI colored line so the caller can observe whether the codes survive to +the stream. Pass ``--dashboard`` to simulate a dashboard-spawned run. +""" + +import sys + +from esphome.core import CORE +from esphome.log import setup_log + +if "--dashboard" in sys.argv: + CORE.dashboard = True + +setup_log() + +print(f"colorama_loaded={'colorama' in sys.modules}") +print("\033[31mred\033[0m end") +sys.stdout.flush() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index 2e09c4a945..b6878c33a2 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -15,7 +15,6 @@ test pins down *which* heavy modules must stay out entirely. from __future__ import annotations import importlib.util -import os from pathlib import Path import subprocess import sys @@ -120,18 +119,17 @@ def test_watched_heavy_modules_exist() -> None: def _leaked_from_fixture( - fixture_path: Path, script_name: str, extra: tuple[str, ...] = () + fixture_path: Path, + env: dict[str, str], + script_name: str, + extra: tuple[str, ...] = (), ) -> str: """Run a fixture script with the watched modules on argv. - Running a script file drops the cwd from sys.path, so prepend the - repo root for the child; a non-zero exit surfaces the child's stderr. + ``env`` comes from the ``probe_env`` fixture so the child can import + the repo checkout; a non-zero exit surfaces the child's stderr. """ script = fixture_path / "lazy_imports" / script_name - python_path = str(Path(__file__).parents[2]) - if ambient := os.environ.get("PYTHONPATH"): - python_path = os.pathsep.join((python_path, ambient)) - env = os.environ | {"PYTHONPATH": python_path} result = subprocess.run( [sys.executable, str(script), *FAST_PATH_HEAVY_MODULES, *extra], capture_output=True, @@ -145,12 +143,13 @@ def _leaked_from_fixture( def test_storage_json_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """``apply_to_core`` runs on the upload/logs fast path for every platform; parsing the stored framework version must not drag in the validation stack or the esp32 component package. """ - leaked = _leaked_from_fixture(fixture_path, "storage_json_fast_path.py") + leaked = _leaked_from_fixture(fixture_path, probe_env, "storage_json_fast_path.py") assert not leaked, ( f"storage_json.apply_to_core pulls in heavy modules: {leaked}. " "The upload/logs fast path skips validation; importing the " @@ -160,12 +159,15 @@ def test_storage_json_fast_path_does_not_import_heavy_modules( def test_esptool_upload_fast_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The esptool serial upload reads the esp32 variant from CORE.data; resolving it must not drag in the esp32 component package or the validation stack. """ - leaked = _leaked_from_fixture(fixture_path, "esptool_upload_fast_path.py") + leaked = _leaked_from_fixture( + fixture_path, probe_env, "esptool_upload_fast_path.py" + ) assert not leaked, ( f"upload_using_esptool pulls in heavy modules: {leaked}. " "The upload fast path skips validation; importing the validation " @@ -266,6 +268,7 @@ def test_yaml_util_does_not_import_heavy_modules() -> None: def test_upload_command_path_does_not_import_heavy_modules( fixture_path: Path, + probe_env: dict[str, str], ) -> None: """The single-config dispatch path checks the bundle suffix on every run; reading it from esphome.const must not drag in esphome.bundle @@ -273,6 +276,7 @@ def test_upload_command_path_does_not_import_heavy_modules( """ leaked = _leaked_from_fixture( fixture_path, + probe_env, "upload_command_fast_path.py", extra=BUNDLE_HEAVY_MODULES + CACHE_HIT_HEAVY_MODULES + STDLIB_FAST_PATH_MODULES, ) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 02798f1029..194b38209b 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,6 +1,44 @@ +from collections.abc import Generator +import errno +import io +import logging +import os +from pathlib import Path +import select +import subprocess +import sys +import time + import pytest -from esphome.log import AnsiFore, AnsiStyle, color +from esphome.core import CORE +from esphome.log import AnsiFore, AnsiStyle, color, setup_log + + +class _FakeTty(io.StringIO): + def isatty(self) -> bool: + return True + + +@pytest.fixture +def restore_logging_state() -> Generator[None, None, None]: + """Undo the global logging changes setup_log() makes.""" + root = logging.getLogger() + handlers = root.handlers[:] + formatters = [handler.formatter for handler in handlers] + level = root.level + urllib3_level = logging.getLogger("urllib3").level + yield + root.handlers[:] = handlers + for handler, formatter in zip(handlers, formatters, strict=True): + handler.setFormatter(formatter) + root.setLevel(level) + logging.getLogger("urllib3").setLevel(urllib3_level) + + +def _probe_command(fixture_path: Path, *args: str) -> list[str]: + """Build the command line for the setup_log probe fixture script.""" + return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args] def test_color_keep_returns_unchanged_message() -> None: @@ -78,3 +116,230 @@ def test_ansi_fore_keep_is_enum_member() -> None: assert bool(AnsiFore.KEEP) is True # But the value itself is still an empty string assert AnsiFore.KEEP.value == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_output_strips_ansi( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A redirected run must keep colorama so ANSI codes are stripped.""" + result = subprocess.run( + _probe_command(fixture_path), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=True" in result.stdout + assert "red end" in result.stdout + assert "\033" not in result.stdout + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """Dashboard runs escape their color codes, so colorama must not load.""" + result = subprocess.run( + _probe_command(fixture_path, "--dashboard"), + capture_output=True, + text=True, + timeout=60, + check=False, + env=probe_env, + ) + assert result.returncode == 0, result.stderr + assert "colorama_loaded=False" in result.stdout + # Codes pass through untouched for the dashboard to handle. + assert "\033[31mred\033[0m end" in result.stdout + + +def _run_probe_on_pty( + fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool +) -> str: + """Run the probe with stdout on a pty and return the decoded pty output. + + With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the + mixed tty/redirect stream combination while keeping any traceback + available for the exit assertion. + """ + # Unix-only; a module-level import would break test collection on + # Windows, where all the callers are skipped anyway. + import pty + + controller, follower = pty.openpty() + proc = None + output = b"" + deadline = time.monotonic() + 60 + try: + try: + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + finally: + os.close(follower) + while True: + timeout = deadline - time.monotonic() + if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: + pytest.fail(f"pty probe produced no EOF in time; got {output!r}") + try: + chunk = os.read(controller, 1024) + except OSError as err: + # macOS raises EIO once the child closes its end of the pty; + # anything else is a real failure, not end-of-stream. + if err.errno != errno.EIO: + raise + break + if not chunk: + break + output += chunk + stderr_text = "" + if proc.stderr is not None: + stderr_text = proc.stderr.read().decode(errors="replace") + proc.stderr.close() + assert proc.wait(60) == 0, stderr_text + finally: + os.close(controller) + if proc is not None and proc.poll() is None: + proc.kill() + proc.wait() + return output.decode() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_tty_skips_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A terminal run must skip colorama and keep ANSI codes intact.""" + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True) + assert "colorama_loaded=False" in text + assert "\033[31mred\033[0m end" in text + + +@pytest.mark.skipif( + sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows" +) +def test_setup_log_mixed_streams_init_colorama( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """A tty stdout with a redirected stderr must still initialize colorama. + + The guard requires both streams to be a tty; collapsing it to a + single-stream check would stop stripping ANSI from a redirected + stderr while stdout is a terminal. + """ + text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False) + assert "colorama_loaded=True" in text + # stdout is a tty, so colorama leaves its codes alone. + assert "\033[31mred\033[0m end" in text + + +@pytest.fixture +def colorama_probe( + monkeypatch: pytest.MonkeyPatch, restore_logging_state: None +) -> Generator[None, None, None]: + """Shared preamble for the in-process guard-branch tests. + + Clears colorama from sys.modules so the assertions prove what + setup_log() itself did, and snapshots CORE.verbose/quiet, which is + not a no-op: CORE.reset() does not restore them, so without the + snapshot setup_log()'s log-level side effects would leak into later + tests. + """ + monkeypatch.delitem(sys.modules, "colorama", raising=False) + monkeypatch.setattr(CORE, "verbose", CORE.verbose) + monkeypatch.setattr(CORE, "quiet", CORE.quiet) + yield + # init() rebinds sys.stdout/stderr; restore them before monkeypatch + # puts the originals back. + if (colorama := sys.modules.get("colorama")) is not None: + colorama.deinit() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_dashboard_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The dashboard side of the guard must not import colorama.""" + monkeypatch.setattr(CORE, "dashboard", True) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_tty_branch_skips_colorama_import( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The tty side of the guard must not import colorama.""" + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" not in sys.modules + + +@pytest.mark.skipif( + sys.platform == "win32", reason="colorama always initializes on Windows" +) +def test_setup_log_redirected_branch_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """Redirected streams must keep importing and initializing colorama.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + setup_log() + assert "colorama" in sys.modules + + +@pytest.mark.parametrize("broken", ["missing", "closed"]) +def test_setup_log_broken_streams_import_colorama( + broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """A missing or closed stream counts as a redirect and must not crash. + + colorama tolerates both, so setup_log() has to reach its init rather + than raise inside the tty probe. + """ + if broken == "missing": + stream = None + else: + stream = io.StringIO() + stream.close() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + setup_log() + assert "colorama" in sys.modules + + +def test_setup_log_win32_always_imports_colorama( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + """The Windows clause must init colorama even when both streams are ttys. + + Old Windows consoles need colorama to translate ANSI escapes, so the + platform check has to win over the tty check. colorama itself keys + off os.name, so on a POSIX host its init/deinit pair is a + passthrough. + """ + monkeypatch.setattr(sys, "platform", "win32") + # Both streams are ttys: without the platform clause this combination + # would skip colorama. + monkeypatch.setattr(sys, "stdout", _FakeTty()) + monkeypatch.setattr(sys, "stderr", _FakeTty()) + setup_log() + assert "colorama" in sys.modules From d9567b2974f2ab6ff149589bae5289cf3ae376c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:05:37 -0500 Subject: [PATCH 051/597] Normalize marker-wrapped callable keys in the schema dump (#18218) --- script/build_language_schema.py | 20 +++++++++- tests/script/test_build_language_schema.py | 43 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index f6dcf00851..2b64cb0256 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1134,13 +1134,29 @@ def convert_keys(converted, schema, path): else: converted["key"] = "String" key_string_match = re.search( - r"", str(k), re.IGNORECASE + r"", str(k), re.IGNORECASE ) if key_string_match: converted["key_type"] = key_string_match.group(1) else: converted["key_type"] = str(k) + # A marker-wrapped callable key (e.g. script.execute's + # ``cv.Optional(validate_parameter_name)``) is a wildcard matcher; + # ``str(marker)`` is the function repr, whose heap address would + # churn the dump every build. Normalize like the bare-callable + # branch above: record the validator name in ``key_type`` and file + # the config var under ``string``. + key_name = str(k) + if isinstance(k, vol.Marker) and callable(k.schema): + key_string_match = re.search( + r"", key_name, re.IGNORECASE + ) + result["key_type"] = ( + key_string_match.group(1) if key_string_match else key_name + ) + key_name = "string" + # ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as # a property that returns ``vol.UNDEFINED`` when the gating # component isn't loaded — and at schema-generation time @@ -1220,7 +1236,7 @@ def convert_keys(converted, schema, path): for base_k, base_v in get_overridden_config(k, converted).items(): if base_k in result and base_v == result[base_k]: result.pop(base_k) - converted["schema"][S_CONFIG_VARS][str(k)] = result + converted["schema"][S_CONFIG_VARS][key_name] = result if "key" in converted and converted["key"] == "String": config_vars = converted["schema"]["config_vars"] assert len(config_vars) == 1 diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8bbaa2773a..f3d4bbcba6 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -3,11 +3,13 @@ from __future__ import annotations import ast +from collections.abc import Callable import importlib.util import json from pathlib import Path import subprocess import sys +from typing import Any import pytest @@ -205,6 +207,47 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: assert "sensitive_source" not in entry +def _wildcard_validator(value: Any) -> Any: + return value + + +def test_convert_keys_marker_wrapped_callable_key_normalizes() -> None: + converted: dict = {} + _bls.convert_keys(converted, {cv.Optional(_wildcard_validator): cv.string}, "/root") + + config_vars = converted["schema"]["config_vars"] + assert set(config_vars) == {"string"} + assert config_vars["string"]["key"] == "Optional" + assert config_vars["string"]["key_type"] == "_wildcard_validator" + + +def test_convert_keys_marker_wrapped_callable_beside_fixed_keys() -> None: + converted: dict = {} + _bls.convert_keys( + converted, + {cv.Required("id"): cv.string, cv.Optional(_wildcard_validator): cv.string}, + "/root", + ) + + assert set(converted["schema"]["config_vars"]) == {"id", "string"} + + +def test_convert_keys_bare_callable_dotted_qualname() -> None: + def make_validator() -> Callable[[Any], Any]: + def validator(value: Any) -> Any: + return value + + return validator + + converted: dict = {} + _bls.convert_keys(converted, {make_validator(): cv.string}, "/root") + + assert converted["key"] == "String" + assert converted["key_type"].endswith("make_validator..validator") + assert "at 0x" not in converted["key_type"] + assert set(converted["schema"]["config_vars"]) == {"string"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # From c8c929d48792ec013935265edb61357f27f91a15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:06:06 -0500 Subject: [PATCH 052/597] [ble_device_base] Merge adv and scan response before delivery on rp2 (#18217) --- .../ble_device_base/scan_response_merger.cpp | 150 ++++++++++++++ .../ble_device_base/scan_response_merger.h | 152 +++++++++++++++ .../components/ln882h_ble_tracker/__init__.py | 3 + .../ln882h_ble_tracker/ln882h_ble_tracker.cpp | 162 ++-------------- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 65 +------ .../components/rp2_ble_tracker/__init__.py | 3 + .../rp2_ble_tracker/rp2_ble_tracker.cpp | 62 +++--- .../rp2_ble_tracker/rp2_ble_tracker.h | 35 ++-- esphome/core/defines.h | 2 + tests/components/ble_device_base/__init__.py | 4 + .../test_scan_response_merger.cpp | 183 ++++++++++++++++++ 11 files changed, 572 insertions(+), 249 deletions(-) create mode 100644 esphome/components/ble_device_base/scan_response_merger.cpp create mode 100644 esphome/components/ble_device_base/scan_response_merger.h create mode 100644 tests/components/ble_device_base/test_scan_response_merger.cpp diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp new file mode 100644 index 0000000000..15445cee02 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -0,0 +1,150 @@ +#include "scan_response_merger.h" + +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include + +namespace esphome::ble_device_base { + +void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + if (this->dispatcher_ == nullptr) + return; + this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, + *this->scan_continuous_ ? nullptr : this->log_tag_); +} + +void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, uint32_t now) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, mac, 6); + slot->addr_type = addr_type; + slot->rssi = rssi; + slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); + memcpy(slot->data, data, slot->data_len); + slot->stored_ms = now; +} + +void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len) { + // Fast-out on the empty table (sweep/flush use the same guard); this is the + // hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (data_len <= room) ? data_len : room; + memcpy(p.data + p.data_len, data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's (header contract). + this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true); +} + +void ScanResponseMerger::sweep(uint32_t now) { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void ScanResponseMerger::flush() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_callback_.is_set()) { + const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; + this->raw_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND the tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + if (!found && log_unclaimed_tag != nullptr) + this->discovered_log_.log_device(log_unclaimed_tag, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void AdvDispatcher::on_scan_end() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h new file mode 100644 index 0000000000..9415664fcf --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -0,0 +1,152 @@ +// Shared support for trackers whose controller delivers advertisement and +// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates +// both into one result before ESPHome sees it): +// +// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is +// held briefly, its scan response is appended on arrival and the pair is +// delivered as ONE merged frame. Merged delivery is what the receiving side +// is built around: Home Assistant keeps the latest raw frame per device and +// skips re-parsing when it is unchanged — split delivery alternates two raw +// frames per device and defeats both. +// +// AdvDispatcher — the delivery half every such tracker repeats: raw +// callback, listener parsing, discovered-device log. Trackers delegate +// their BLEHub register_listener / set_raw_advertisement_callback here. +// +// The merger delivers straight into the tracker's AdvDispatcher — bind() wires +// the pair once in setup(). Single-task use only (every tracker calls this on +// the ESPHome main task). The clock is caller-provided: pass the same clock to +// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(), +// never mixed). + +#pragma once + +#include "esphome/core/defines.h" + +// Emitted (cg.add_define) by each tracker that adopts the merger, so builds +// whose tracker merges in-stack (esp32) never compile this code. +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "ble_device.h" +#include "ble_hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +/// The delivery half of a split-report tracker, shared so the dispatch +/// contract (raw-callback ordering, raw_only gate, discovered-log policy) +/// lives in one place. Owns the members every tracker otherwise duplicates; +/// the tracker's BLEHub methods delegate here. +class AdvDispatcher { + public: + void register_listener(ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; } + /// Dispatch one (possibly merged) advertisement: the raw callback, and — + /// unless raw_only — parsing for listeners/triggers. raw_only marks + /// unmatched scan-response frames: forwarded on the raw callback only, never + /// parsed for local sensors/triggers (Home Assistant merges per address). + /// log_unclaimed_tag: when non-null, a device no listener claimed is logged + /// under this tag (esp32_ble_tracker parity: pass the tracker TAG on + /// one-shot scans, nullptr on continuous scans, which would spam). + void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag); + /// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup. + void on_scan_end(); + + protected: + RawAdvertisementCallback raw_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only + // writer so a no-listener build does not carry an unused vector. + DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +class ScanResponseMerger { + public: + /// Wire the merger's output; call once in the tracker's setup(). Every + /// delivered frame goes to dispatcher->dispatch(); scan_continuous is read + /// at each delivery (runtime continuous flips are honored) to decide the + /// unclaimed-device log tag, so both pointers must outlive the merger — + /// tracker members always do. + void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) { + this->dispatcher_ = dispatcher; + this->scan_continuous_ = scan_continuous; + this->log_tag_ = log_tag; + } + /// Hold a scannable advertisement, waiting for its scan response. The + /// tracker calls this only when it wants the merge (scannable advertisement + /// while an active scan runs) and delivers everything else directly. A + /// same-device re-advertisement delivers the held frame (its scan response + /// is not coming) and reuses the slot; a full table degrades gracefully to + /// unmerged delivery. + void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + uint32_t now); + /// A scan response arrived: append it to the held advertisement from the + /// same device and deliver the pair as one frame. The merged frame reports + /// the ADVERTISEMENT's RSSI — every unmerged path reports the + /// advertisement's measurement, so a device's RSSI must not jump between two + /// measurements depending on merge timing. Unmatched responses are delivered + /// raw_only. + void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len); + /// Timeout flush (call from loop() with the stash_adv() clock): deliver + /// held advertisements whose scan response never arrived (device didn't + /// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS. + void sweep(uint32_t now); + /// Deliver every held advertisement now (scan period/scan is ending, before + /// on_scan_end fires): unmerged delivery, same as the timeout path. + void flush(); + /// Lets loop() skip the cross-TU sweep() call in the common case (empty: + /// passive scan, or every pair already matched). + bool empty() const { return this->pending_count_ == 0; } + + private: + /// All delivery funnels through here: an unbound merger (bind() not called) + /// drops the frame instead of jumping through a null pointer, mirroring the + /// guard-before-invoke convention of the ble_hub.h callback slots. + void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum + // as ESP-IDF delivers on ESP32. + struct PendingAdv { + bool used{false}; + uint8_t mac[6]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one report-queue drain, so a slot is held for the full timeout only + // by scannable devices that never reply. 8 concurrent such advertisers + // before the merge degrades (frames still delivered, just unmerged) at + // ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing under WiFi/BLE coexistence, + // measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + AdvDispatcher *dispatcher_{nullptr}; + const bool *scan_continuous_{nullptr}; // read at delivery; see bind() + const char *log_tag_{nullptr}; + // pending_count_ mirrors the number of set `used` flags; both are updated + // together on every transition. + PendingAdv pending_adv_[MAX_PENDING_ADV]; + uint8_t pending_count_{0}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 45f1b95164..8443799144 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -129,6 +129,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_LN882H_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the LN controller + # delivers the pair as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index cddcd6c17d..11ea46525c 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -3,7 +3,6 @@ #include "ln882h_ble_tracker.h" #include -#include #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -22,6 +21,9 @@ void LN882HBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // rw task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); // scan_running_ check: an on_boot start_scan action (priority 600) runs // before this setup() (200) and enable_loop() is a no-op pre-setup — parking // the loop here would strand that already-running scan. @@ -72,19 +74,11 @@ void LN882HBLETracker::loop() { this->start_scan_(); } } - // Flush pending scannable advertisements whose scan response never arrived - // (device didn't answer / frame lost) — delivered unmerged after the timeout. - // Main-task only, like every consumer of pending_adv_. + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. Main-task only, like every merger call. const uint32_t now = millis(); - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - } + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_continuous_) { if (!this->scan_running_) { @@ -145,129 +139,25 @@ void LN882HBLETracker::dump_config() { } // --------------------------------------------------------------------------- -// Adv/scan-response demux with Bluedroid-style merge: the LN controller -// delivers the pair as separate reports; a scannable advertisement is held -// until its scan response arrives and delivered as one merged frame. +// Adv/scan-response demux into the shared merger (ble_device_base): the LN +// controller delivers the pair as separate reports; a scannable advertisement +// is held until its scan response arrives and delivered as one merged frame. // --------------------------------------------------------------------------- void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { if (report.is_scan_response) { - this->deliver_scan_rsp_(report); + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); return; } // Stash only while the scan runs: after a one-shot stop the loop is - // disabled and nothing would sweep the table, so a late report would + // disabled and nothing would sweep the merger, so a late report would // surface minutes later as a fresh advertisement. if (this->scan_running_ && this->scan_active_ && report.scannable) { - this->stash_adv_(report); + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis()); return; } - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); -} - -// Hold a scannable advertisement, waiting (≤ PENDING_ADV_TIMEOUT_MS) for its -// scan response. -void LN882HBLETracker::stash_adv_(const ln882h_ble::BLEScanReport &report) { - // One pass: find a same-device entry (deliver + reuse) while remembering the - // first free slot as the fallback. - PendingAdv *slot = nullptr; - PendingAdv *free_slot = nullptr; - for (auto &p : this->pending_adv_) { - if (!p.used) { - if (free_slot == nullptr) - free_slot = &p; - continue; - } - if (p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Same device advertised again before its scan response arrived — deliver - // the previous advertisement (its scan response is not coming) and reuse - // the slot, so no frame is ever lost. - p.used = false; - this->pending_count_--; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - slot = &p; - break; - } - } - if (slot == nullptr) - slot = free_slot; - if (slot == nullptr) { - // Table full — degrade gracefully: deliver the advertisement unmerged. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/false); - return; - } - slot->used = true; - this->pending_count_++; - memcpy(slot->mac, report.mac, 6); - slot->addr_type = report.addr_type; - slot->rssi = report.rssi; - slot->data_len = (report.data_len <= sizeof(slot->data)) ? report.data_len : sizeof(slot->data); - memcpy(slot->data, report.data, slot->data_len); - slot->stored_ms = millis(); -} - -// Scan response arrived: merge it with the pending advertisement from the same -// device into ONE frame (ESP-IDF/Bluedroid semantics). -void LN882HBLETracker::deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report) { - // Fast-out on the empty table (loop()/flush use the same guard); this is - // the hottest caller. - if (this->pending_count_ != 0) { - for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == report.addr_type && memcmp(p.mac, report.mac, 6) == 0) { - // Append in place: the slot is released on delivery, so its 62-byte - // buffer (legacy adv + scan response) holds the merged frame directly. - const uint8_t room = sizeof(p.data) - p.data_len; - const uint8_t add = (report.data_len <= room) ? report.data_len : room; - memcpy(p.data + p.data_len, report.data, add); - p.used = false; - this->pending_count_--; - // The advertisement's RSSI, not the scan response's: every unmerged path - // reports the advertisement's measurement, so a device's RSSI must not - // jump between two measurements depending on merge timing. - this->process_adv_(report.mac, p.rssi, report.addr_type, p.data, p.data_len + add, /*raw_only=*/false); - return; - } - } - } - // Unmatched scan-response: goes out on the raw callback only (HA merges per - // address); local listeners/triggers receive each advertisement exactly once - // via the merged/plain path above. - this->process_adv_(report.mac, report.rssi, report.addr_type, report.data, report.data_len, /*raw_only=*/true); -} - -void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint8_t data_len, bool raw_only) { - // Raw callback (the raw-advertisement path). Both full advertisements and - // unmatched scan responses (raw_only) are forwarded. - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac), - .data = data, - .data_len = data_len, - .rssi = rssi, - .addr_type = addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Scan-response-only frames are never parsed for local sensors/triggers. - if (raw_only) - return; - ble_device_base::ESPBTDevice device; - device.from_scan_result(mac, rssi, addr_type, data, data_len); - // The listener list holds sensors AND this tracker's automation triggers - // (the triggers are listeners, exactly like esp32_ble_tracker), so one - // loop feeds both and ORs into `found`. - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } - } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -356,29 +246,11 @@ void LN882HBLETracker::stop_scan_() { // Close a scan period: deliver held advertisements whose scan response never // came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. void LN882HBLETracker::end_scan_period_(uint32_t now) { - this->flush_pending_adv_(); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->merger_.flush(); + this->dispatcher_.on_scan_end(); this->scan_period_start_ = now; } -// Deliver every held advertisement now (scan period/scan is ending): unmerged -// delivery, same as the timeout path in loop(). Main-task only. -void LN882HBLETracker::flush_pending_adv_() { - if (this->pending_count_ == 0) - return; - for (auto &p : this->pending_adv_) { - if (p.used) { - p.used = false; - this->process_adv_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); - } - } - this->pending_count_ = 0; -} - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 9c0e0b2f1a..2d88b938dd 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -9,6 +9,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/ln882h_ble/ln882h_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -75,12 +76,10 @@ class LN882HBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive @@ -108,27 +107,11 @@ class LN882HBLETracker : public Component, void on_scan_report(const ln882h_ble::BLEScanReport &report) override; protected: - // Bluedroid-style adv + scan-response merging (ESP-IDF concatenates both into - // one result before ESPHome sees it; the LN controller reports them separately): - // a scannable advertisement is held here briefly, its scan response is appended - // on arrival and the pair is delivered as ONE merged frame. Held entries whose - // scan response never arrives are flushed by loop() after PENDING_ADV_TIMEOUT_MS. - // All of this runs on the main task (the controller queue already crossed tasks), - // so no locking is involved. - void stash_adv_(const ln882h_ble::BLEScanReport &report); - void deliver_scan_rsp_(const ln882h_ble::BLEScanReport &report); - // Dispatch one (possibly merged) advertisement: the raw - // callback, and — unless raw_only — parsing for listeners/triggers. raw_only - // marks unmatched scan-response frames: forwarded on the raw callback only, - // never to local sensors/triggers (HA merges per address). - void process_adv_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, - bool raw_only); void start_scan_(); void stop_scan_(); // Close a scan period: flush held advertisements (unmerged) BEFORE // on_scan_end fires, then re-anchor the period clock to `now`. void end_scan_period_(uint32_t now); - void flush_pending_adv_(); bool scan_running_{false}; bool scan_active_{false}; @@ -147,45 +130,13 @@ class LN882HBLETracker : public Component, #endif uint32_t scan_start_time_{0}; - // Pending scannable advertisements awaiting their scan response (active scan). - // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum as - // ESP-IDF delivers on ESP32. Main-task only. - struct PendingAdv { - bool used{false}; - uint8_t mac[6]; - uint8_t addr_type; - int8_t rssi; - uint8_t data_len; // <= sizeof(data) - uint8_t data[62]; - uint32_t stored_ms; - }; - // Sized for the unanswered case: a pair that IS answered normally matches - // within one queue drain, so a slot is held for the full timeout only by - // scannable devices that never reply. 8 concurrent such advertisers before - // the merge degrades (frames still delivered, just unmerged) at ~80 B each. - static constexpr size_t MAX_PENDING_ADV = 8; - // On air a scan response follows its advertisement by T_IFS (150 µs) — the - // timeout only covers HOST-side report queuing in rw_task under WiFi/BLE - // coexistence, measured on-device at up to ~136 ms. 300 ms = >2x that margin, - // while staying below any device's re-advertising period. - static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; - PendingAdv pending_adv_[MAX_PENDING_ADV]; - // Occupied pending_adv_ slots — lets loop()'s timeout sweep skip the table - // in the common case (empty: passive scan, or every pair already matched). - uint8_t pending_count_{0}; + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks); the merger is clocked by millis() throughout this tracker. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() - - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif }; } // namespace esphome::ln882h_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 15c1229a85..99262babce 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -57,6 +57,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_RP2_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (BTstack delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index c2bb93a32e..2a87d617f8 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -24,6 +24,9 @@ void RP2BLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BTstack packet handler (IRQ) and delivers here on the main loop. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — the BLE scan competes with // the OTA download on the shared CYW43 radio. Mirrors esp32_ble_tracker. @@ -64,6 +67,10 @@ void RP2BLETracker::on_ota_global_state(ota::OTAState state, float progress, uin void RP2BLETracker::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); if (this->scan_running_ && !this->parent_->is_active()) { // The controller was disabled underneath us (e.g. a lambda calling // rp2040_ble's disable()); the scan died with the stack. Reconcile so the @@ -119,30 +126,32 @@ void RP2BLETracker::dump_config() { YESNO(this->scan_continuous_)); } -void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { - // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAP advertising event types as BTstack reports them (Core spec advertising +// report event types; the tracker deliberately does not include BTstack +// headers). ADV_IND and ADV_SCAN_IND are the scannable types. +static constexpr uint8_t ADV_EVENT_TYPE_ADV_IND = 0; +static constexpr uint8_t ADV_EVENT_TYPE_ADV_SCAN_IND = 2; +static constexpr uint8_t ADV_EVENT_TYPE_SCAN_RSP = 4; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; +// Demux advertisements vs scan responses into the shared merger: BTstack +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) { + if (report.adv_event_type == ADV_EVENT_TYPE_SCAN_RSP) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && + (report.adv_event_type == ADV_EVENT_TYPE_ADV_IND || report.adv_event_type == ADV_EVENT_TYPE_ADV_SCAN_IND)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } void RP2BLETracker::start_scan() { @@ -229,11 +238,10 @@ void RP2BLETracker::stop_scan_() { } void RP2BLETracker::fire_scan_end_() { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); } } // namespace esphome::rp2_ble_tracker diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 054f6a65d2..02bd7dc145 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -4,6 +4,7 @@ #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/components/rp2040_ble/rp2040_ble.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -51,25 +52,22 @@ class RP2BLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // BTstack delivers scan responses as separate advertisement reports rather - // than merging them into the advertisement — consumers relying on - // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). GATT is available when the - // BTstack connection backend is compiled in (bluetooth_proxy active). + // BTstack delivers scan responses as separate advertisement reports; this + // tracker merges the pair before delivery (shared ScanResponseMerger, + // Bluedroid semantics). GATT is available when the BTstack connection + // backend is compiled in (bluetooth_proxy active). #ifdef USE_BLE_GATT_CLIENT constexpr bool has_gatt = true; #else constexpr bool has_gatt = false; #endif - return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; + return {.active_scan = true, .merges_scan_response = true, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. @@ -104,16 +102,13 @@ class RP2BLETracker : public Component, bool scan_pending_before_ota_{false}; // one-shot scan in flight at OTA start, resumed on OTA failure #endif - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main loop. Merger clock: stash_adv() reads the + // PARENT's cached loop time (on_scan_report runs inside rp2040_ble's queue + // drain), sweep() this component's — same App.loop() pass, so the delta + // stays non-negative and the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::rp2_ble_tracker diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ad24d27369..7be217383e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -469,6 +469,7 @@ #define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT #define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define USE_RP2040_VARIANT_RP2040 @@ -500,6 +501,7 @@ #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/components/ble_device_base/__init__.py b/tests/components/ble_device_base/__init__.py index 1b041df8df..4dbd0becd8 100644 --- a/tests/components/ble_device_base/__init__.py +++ b/tests/components/ble_device_base/__init__.py @@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # resolve_irk() is compiled only when a sensor configures irk: # (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has # no sensors, so emit the define here to put the real IRK path under test. + # Likewise the scan-response merger (emitted by the split-report trackers) + # and the listener vector it dispatches into (codegen-sized by consumers). async def to_code_testing(config): cg.add_define("USE_BLE_DEVICE_IRK") + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4) manifest.to_code = to_code_testing diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp new file mode 100644 index 0000000000..013c7bf8f9 --- /dev/null +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -0,0 +1,183 @@ +// The host test build gets this from the manifest override; clang-tidy does not. +#ifndef USE_BLE_SCAN_RESPONSE_MERGER +#define USE_BLE_SCAN_RESPONSE_MERGER +#endif + +#include + +#include +#include +#include + +#include "esphome/components/ble_device_base/scan_response_merger.h" + +namespace esphome::ble_device_base::testing { +namespace { + +// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot +// bookkeeping, the same-device reuse path, the table-full fallback, the +// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate. +// Delivery is observed through a real AdvDispatcher: the raw callback sees +// every frame (including raw_only), a listener only the parsed ones. + +struct DeliveredFrame { + uint64_t address; + std::vector data; + int8_t rssi; +}; + +struct RawCapture { + std::vector frames; + + static void trampoline(void *self, const RawAdvertisement &adv) { + auto *capture = static_cast(self); + capture->frames.push_back({adv.address, std::vector(adv.data, adv.data + adv.data_len), adv.rssi}); + } +}; + +class CountingListener : public ESPBTDeviceListener { + public: + bool parse_device(const ESPBTDevice &device) override { + this->parsed++; + return true; // claimed: keeps the discovered log quiet + } + int parsed{0}; +}; + +class ScanResponseMergerTest : public ::testing::Test { + protected: + void SetUp() override { + this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline}); + this->dispatcher_.register_listener(&this->listener_); + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test"); + } + + void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) { + std::vector data(data_len, fill); + this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now); + } + + void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) { + std::vector data(data_len, fill); + this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len); + } + + ScanResponseMerger merger_; + AdvDispatcher dispatcher_; + RawCapture raw_; + CountingListener listener_; + bool scan_continuous_{true}; +}; + +constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; +constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16}; + +TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) { + this->stash_(MAC_A, -40, 20, 0xAA); + EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered + + this->scan_rsp_(MAC_A, -70, 10, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 1u); + const auto &frame = this->raw_.frames[0]; + ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame + EXPECT_EQ(frame.data[0], 0xAA); + EXPECT_EQ(frame.data[19], 0xAA); + EXPECT_EQ(frame.data[20], 0xBB); + // The advertisement's RSSI, never the scan response's. + EXPECT_EQ(frame.rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) { + this->stash_(MAC_A, -40, 20, 0xAA); + this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 20u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot + + this->scan_rsp_(MAC_A, -70, 5, 0xBB); + ASSERT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot + EXPECT_EQ(this->raw_.frames[1].rssi, -45); +} + +TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) { + uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00}; + for (uint8_t i = 0; i < 8; i++) { + mac[5] = i; + this->stash_(mac, -50, 10, i); + } + EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held + + mac[5] = 8; + this->stash_(mac, -50, 10, 8); // 9th device: no slot left + ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + + this->merger_.flush(); // the 8 held frames are all still intact + EXPECT_EQ(this->raw_.frames.size(), 9u); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) { + this->stash_(MAC_A, -40, 31, 0xAA); + this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 62u); + EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB); + EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB); +} + +TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) { + this->scan_rsp_(MAC_B, -60, 12, 0xDD); + ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path + EXPECT_EQ(this->raw_.frames[0].rssi, -60); + EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners +} + +TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) { + std::vector adv(20, 0xAA); + this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0); + std::vector rsp(10, 0xBB); + this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size()); + // Same MAC, different addr_type: no merge — the response goes out raw_only. + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].data.size(), 10u); + EXPECT_EQ(this->listener_.parsed, 0); + EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held +} + +TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout + EXPECT_TRUE(this->raw_.frames.empty()); + this->merger_.sweep(1301); + ASSERT_EQ(this->raw_.frames.size(), 1u); + EXPECT_EQ(this->raw_.frames[0].rssi, -40); + EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) { + this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000); + this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000); + this->merger_.flush(); + EXPECT_EQ(this->raw_.frames.size(), 2u); + EXPECT_EQ(this->listener_.parsed, 2); + EXPECT_TRUE(this->merger_.empty()); +} + +TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { + ScanResponseMerger unbound; + std::vector data(20, 0xAA); + unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + unbound.sweep(1000); + unbound.flush(); // no null jump anywhere + EXPECT_TRUE(unbound.empty()); +} + +} // namespace +} // namespace esphome::ble_device_base::testing From f3d1fc0d643ceaba252e8a0ecaeecf127a0c1bcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 10:47:56 -0500 Subject: [PATCH 053/597] [bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198) --- .../components/ble_device_base/__init__.py | 5 +- .../ble_device_base/ble_client_state.h | 10 + .../ble_device_base/ble_gatt_client.h | 76 +- .../bluetooth_connection/__init__.py | 180 +++- .../bluetooth_connection.cpp | 27 + .../bluetooth_connection.h | 13 +- .../bluetooth_connection_bluedroid.cpp | 772 ++++++++++++++++++ .../bluetooth_connection_bluedroid.h | 142 ++++ .../bluetooth_connection_esp32.cpp | 484 ----------- .../bluetooth_connection_esp32.h | 76 -- .../bluetooth_connection_gatt_backend.h | 13 +- .../bluetooth_connection_hub.cpp | 93 +-- .../bluetooth_connection_hub.h | 131 +-- .../bluetooth_connection_rp2.cpp | 45 +- .../bluetooth_connection_rp2.h | 15 +- .../components/bluetooth_proxy/__init__.py | 99 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 140 ++-- .../bluetooth_proxy/bluetooth_proxy.h | 11 +- esphome/config_helpers.py | 15 +- esphome/core/defines.h | 2 + .../ble_device_base/test_slot_counter.py | 2 + .../test_outer_schema_mirror.py | 11 +- .../bluetooth_proxy/test_platform_gates.py | 58 +- .../test_gatt_client_contract.cpp | 45 +- .../test-passive.esp32-c6-idf.yaml | 12 + tests/unit_tests/test_config_helpers.py | 17 +- 26 files changed, 1518 insertions(+), 976 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.h create mode 100644 tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index fa66448867..ae03003713 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -163,8 +163,9 @@ _request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) def request_gatt_client() -> None: """Compile in the neutral GATT client contract (ble_gatt_client.h) and - claim one connection slot. Called by bluetooth_proxy once per connection - it instantiates on a hub platform.""" + claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT; + distinct from the proxy's validated connection budget). Called by + bluetooth_connection.new_gatt_backend() once per backend instance.""" cg.add_define("USE_BLE_GATT_CLIENT") _request_gatt_connection_slot() diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index b0c91397fc..92754b70b4 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -17,6 +17,16 @@ namespace esphome::ble_device_base { /// client backend. static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency, +/// e.g. a service table failing its own bounds checks. +static constexpr int GATT_ERR_UNLIKELY = 0x0E; + +/// Safety net shared by every GATT backend: force IDLE when the stack never +/// delivers its disconnect completion. +static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000; + +/// ATT MTU before negotiation completes (Bluetooth spec default). +static constexpr uint16_t DEFAULT_ATT_MTU = 23; // Preferred connection parameters shared by every platform's GATT client so // the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 74548f578f..b95fb6878a 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,10 +5,11 @@ // Exactly one GATT backend exists per build, so BLEGattConnection is a // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // interface. -// The hub BluetoothConnection wrapper drives it and receives completions -// through its event-sink methods, which the backend calls directly. All sink -// calls are delivered on the ESPHome main loop; borrowed data pointers are -// valid only for the duration of the call. +// A consumer - the hub wrapper streaming the raw database, or a direct +// consumer owning a dedicated backend and resolving handles by UUID - +// drives it and receives completions through the GattClientListener +// interface. All listener calls are delivered on the ESPHome main loop; +// borrowed data pointers are valid only for the duration of the call. // // Error domain (plain int, forwarded to the API without translation): // 0 success @@ -78,27 +79,55 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; +/// The event surface a backend delivers completions through - the one place +/// with genuine runtime polymorphism (several consumer types, one non-virtual +/// backend). Methods default to no-ops; consumers override what they consume. +/// No destructor: components are never destroyed. +/// on_connection_state carries the negotiated MTU and an HCI status/reason. +/// Codegen wires the listener before setup(), so backends skip null checks. +class GattClientListener { + public: + virtual void on_connection_state(bool connected, uint16_t mtu, int error) {} + virtual void on_service_discovery_done(int error) {} + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + virtual void on_write_result(uint16_t handle, int error) {} + virtual void on_notify_state(uint16_t handle, bool enabled, int error) {} + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + virtual void on_pairing_result(int status) {} +}; + // The BLEGattConnection op surface, asserted where the alias binds // (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives -// through the sink) or a synchronous error (busy, not connected, stack +// through the listener) or a synchronous error (busy, not connected, stack // rejection); one operation may be outstanding at a time. Semantics beyond // the signatures: // - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). -// - disconnect: also cancels a connect in progress. +// - gatt_disconnect: also cancels a connect in progress (named to coexist +// with a platform stack's own void disconnect() on one backend class). +// Nonzero means nothing to tear down and no completion will follow; an +// accepted teardown (0) always reaches a terminal on_connection_state. +// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not +// started closing - the in-flight connect resumes and completes normally. +// False once the teardown owns the link (or nothing was scheduled). // - notify_characteristic: local registration only; the CCCD write is the // API client's responsibility (a plain write_descriptor). // - get_service_table/release_services: backend-owned transient storage, -// released after streaming (release is idempotent). -// - completions: connect and disconnect land in on_connection_state, +// released after streaming (release is idempotent). A backend may +// additionally provide its own service streamer (stream_service_batch on +// the concrete type, detected by the consumer at compile time) for +// arbitrary-size databases; the table then materializes only for consumers +// that ask for it. +// - completions: connect and gatt_disconnect land in on_connection_state, // discover_services in on_service_discovery_done, pair in // on_pairing_result, reads in on_read_result, notify_characteristic in -// on_notify_state, characteristic writes with response and descriptor -// writes in on_write_result. -template -concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { - conn.set_listener(sink); +// on_notify_state, characteristic writes (with and without response) and +// descriptor writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) { + conn.set_listener(listener); { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; - { conn.disconnect() } -> std::same_as; + { conn.gatt_disconnect() } -> std::same_as; + { conn.cancel_gatt_disconnect() } -> std::same_as; { conn.discover_services() } -> std::same_as; { conn.read_characteristic(uint16_t{}) } -> std::same_as; { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; @@ -109,22 +138,9 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t * { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; { conn.get_service_table() } -> std::same_as; { conn.release_services() } -> std::same_as; -}; - -// The event sink the backend calls directly (the hub BluetoothConnection -// wrapper), asserted where the wrapper is defined: on_connection_state -// carries the negotiated MTU and an HCI status/disconnect reason. The -// requirements check call validity, not exact parameter types; keep sink -// parameters at the documented widths (uint16_t handles and lengths). -template -concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { - { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; - { sink.on_service_discovery_done(int{}) } -> std::same_as; - { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; - { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; - { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; - { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; - { sink.on_pairing_result(int{}) } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1dc1969a6a..8c218c0954 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,23 +1,34 @@ -"""Per-platform GATT connection backends the Bluetooth proxy drives. +"""Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no -user-facing configuration; the proxy's codegen declares and registers the -connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). """ -import functools +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PLATFORM_RP2, PlatformFramework +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework from esphome.core import CORE +from esphome.types import ConfigType def AUTO_LOAD() -> list[str]: - """The esp32 connection header includes esp32_ble_client, so the closure - must be self-satisfying; no target platform (tooling) gets the union.""" - if CORE.is_esp32 or CORE.target_platform is None: - return ["ble_device_base", "esp32_ble_client"] + """ble_device_base plus the platform BLE stack the build's backend + registers with (the Bluedroid header includes the tracker's), so + consumers need not know. The platform-less arm serves manifest tooling.""" + if CORE.is_esp32: + return ["ble_device_base", "esp32_ble_tracker"] + if CORE.is_rp2: + return ["ble_device_base", "rp2040_ble"] + if CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"] return ["ble_device_base"] @@ -29,39 +40,134 @@ bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") # raising this needs an upstream change (the layer itself supports N). RP2_MAX_CONNECTIONS = 1 -# Hub platforms with a GATT backend, mapped to their slot limit — the single -# registry of which hub platforms run the connection-capable proxy. +# Slot limits for the hub platforms running the connection-capable proxy; +# the backend registry itself is _PLATFORM_BACKENDS below. HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} -# The hub-platform wrapper and the rp2 BTstack backend codegen classes. +# The hub-platform wrapper and the backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) +BluedroidGattClient = bluetooth_connection_ns.class_( + "BluedroidGattClient", cg.Component +) + +CONF_BACKEND_ID = "backend_id" -@functools.cache -def esp32_connection_class() -> cg.MockObjClass: - """Lazy: importing esp32_ble_client registers esp32-only automations as - an import side effect, which must not leak into other platforms.""" - from esphome.components import esp32_ble_client +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker - return bluetooth_connection_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA + + +def _rp2_schema_fragment() -> cv.Schema: + from esphome.components import rp2040_ble + + return cv.Schema( + {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "bluetooth_connection_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - # Every hub platform the proxy admits (the file compiles empty where - # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend - # cannot hit a missing-symbol trap here. - "bluetooth_connection_hub.cpp": { - PlatformFramework.RP2_ARDUINO, - PlatformFramework.LN882X_ARDUINO, - }, - "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, - } -) +async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import esp32_ble_tracker + + # The tracker's promote loop owns connect timing; the backend registers + # as a raw client (it is the tracker's ESPBTClient). + await esp32_ble_tracker.register_raw_client(backend, config) + + +async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import rp2040_ble + + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + + +@dataclass(frozen=True) +class _PlatformBackend: + """One platform's backend: codegen class, extra schema keys (lazy so the + platform stack is only imported when targeted), and stack registration.""" + + backend_class: cg.MockObjClass + schema_fragment: Callable[[], cv.Schema] + register: Callable[[cg.MockObj, ConfigType], Awaitable[None]] + + +# The single registry of platforms with a GATT client backend; a platform +# missing here fails loudly everywhere instead of falling into another +# platform's arm. +_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { + PLATFORM_ESP32: _PlatformBackend( + BluedroidGattClient, _esp32_schema_fragment, _esp32_register + ), + PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register), +} + + +def _backend_entry(platform: str | None = None) -> _PlatformBackend: + key = platform if platform is not None else CORE.target_platform + if (entry := _PLATFORM_BACKENDS.get(key)) is None: + raise cv.Invalid(f"no GATT client backend is registered for {key}") + return entry + + +def gatt_client_schema(platform: str | None = None) -> cv.Schema: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. + + Defaults to the platform being validated; pass `platform` explicitly when + building a schema outside validation (the language-schema dumper calls + per-platform builders under arbitrary CORE platforms). + """ + entry = _backend_entry(platform) + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} + ) + + +def hub_connection_schema(platform: str | None = None) -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment, plus the component keys (setup_priority and + friends now apply to the backend, the slot's real Component). Same + platform rules as gatt_client_schema().""" + return ( + gatt_client_schema(platform) + .extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + # The backend is the slot's real Component: component keys from the + # connection entry (setup_priority, ...) apply to it. Consumers whose own + # schema carries keys that register_component would misapply to the + # backend (e.g. a polling interval) must not put them in this config. + await cg.register_component(backend, config) + await _backend_entry().register(backend, config) + return backend + + +# Named so tests can pin the hub entry against bluetooth_proxy's platform +# list (this module cannot import bluetooth_proxy to derive it). +SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { + "bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]), + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, +} + +FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 57833edbd2..94bb119c84 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -1,5 +1,10 @@ #include "bluetooth_connection.h" +#ifdef USE_ESP32 +#include +#include +#endif + #ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" @@ -40,3 +45,25 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_HAS_GATT + +#ifdef USE_ESP32 +namespace esphome::bluetooth_connection { + +// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, +// including advertisement-only ones where no GATT backend (and none of the +// gated surface above) is compiled - so this block sits outside that gate. + +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_remove_bond_device(bda); +} + +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_gattc_cache_clean(bda); +} + +} // namespace esphome::bluetooth_connection +#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 2125d5b34f..5052e7eca1 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,10 +16,15 @@ #include #endif -// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub -// platform with the neutral GATT client compiled in. Single-sourced here so -// the proxy and this component cannot drift. -#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +// The connection-aware API request handlers are compiled: a GATT backend is +// wired by codegen (one slot per connection). This is the single spelling of +// that predicate - the hub wrapper and the API request handlers gate on it. +// The wrapper serves the proxy's API surface, so it compiles only when a +// backend AND the proxy are present; advertisement-only and backend-only +// builds get the clean-error handlers instead. Address-scoped maintenance +// (unpair, cache clear) still works there through the per-platform free +// functions below. +#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) #define BLUETOOTH_CONNECTION_HAS_GATT #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..f24d261c57 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,772 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY +#include "bluetooth_connection.h" +#include "bluetooth_connection_hub.h" + +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#endif + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.bluedroid"; + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; +using esp32_ble_tracker::ClientState; +using esp32_ble_tracker::ConnectionType; + +// ---- tracker surface ---- + +void BluedroidGattClient::connect() { this->tracker_connect_(); } +void BluedroidGattClient::disconnect() { this->gatt_disconnect(); } + +// ---- component ---- + +void BluedroidGattClient::setup() { + static uint8_t connection_index = 0; + this->connection_index_ = connection_index++; +} + +void BluedroidGattClient::loop() { + if (!esp32_ble::global_ble->is_active()) { + // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer + // frees its slot, then re-register the app on the next enable. + auto down_st = this->state(); + if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + this->set_state(ClientState::INIT); + return; + } + auto st = this->state(); + if (st == ClientState::INIT) { + // Parity with BLEClientBase: a failed registration marks the slot + // failed and idles it without retry. + auto ret = esp_ble_gattc_app_register(this->app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_idle_(); + } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { + // The one teardown safety net: a lost CLOSE_EVT, or a scheduled + // teardown whose OPEN_EVT never arrives. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); + // Release before idling: a lost completion must not leak the cache. + this->release_services(); + this->set_idle_(); // also clears want_disconnect_ + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); + } + } else { + // The loop stays on while a link exists (stack-down watch, pre-started + // search flush); it settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +void BluedroidGattClient::dump_config() { + ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); + if (this->is_failed()) { + ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); + } +} + +// ---- contract ops ---- + +int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + // Only from idle: clobbering DISCONNECTING would open a new link the + // stale CLOSE_EVT then tears down. + if (this->state() != ClientState::IDLE) { + ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_); + return ESP_GATT_BUSY; + } + ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_); + this->remote_addr_type_ = addr_type; + // Hand the request to the tracker's promote loop: it stops the scan, raises + // coex, and calls tracker_connect_() - the tracker owns connect timing here. + this->set_state(ClientState::DISCOVERED); + return 0; +} + +void BluedroidGattClient::tracker_connect_() { + auto st = this->state(); + if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_); + return; + } + if (st == ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_); + return; + } + ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_); + // Per-attempt latches; the search machine is reset by set_idle_(), the + // one door back to IDLE. + this->services_released_ = false; + this->seen_mtu_ = false; + this->mtu_failed_ = false; + this->enable_loop(); + this->set_state(ClientState::CONNECTING); + if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { + // Fast params for the discovery phase; stepped down at SEARCH_CMPL. + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, + FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT)); + } else { + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, + MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT)); + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, + static_cast(this->remote_addr_type_), true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired; nothing to close. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ret); + } +} + +int BluedroidGattClient::gatt_disconnect() { + auto st = this->state(); + if (st == ClientState::DISCONNECTING) { + return 0; + } + // Nothing was opened, so no completion event will follow: report + // not-connected and the hub frees the slot at once (rp2 convention). + if (st == ClientState::IDLE) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::DISCOVERED) { + // Parked for the tracker promote loop, never opened. + this->set_idle_(); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_); + this->want_disconnect_ = true; + // Arm the safety window: a lost OPEN_EVT must not leak the teardown. + this->disconnecting_started_ = millis(); + this->enable_loop(); + return 0; + } + this->unconditional_disconnect_(); + return 0; +} + +void BluedroidGattClient::unconditional_disconnect_() { + ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); + if (this->conn_id_ == UNSET_CONN_ID) { + // Terminal state now rather than leaning on the scheduled-teardown timer. + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + return; + } + auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); + if (err != ESP_OK) { + // The stack is now in an indeterminate state for this link. + ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err); + } + this->set_disconnecting_(); +} + +bool BluedroidGattClient::cancel_gatt_disconnect() { + // Only a scheduled teardown (want_disconnect_ latched while the open is + // still in flight) is cancellable; once closing started the terminal + // report settles the race. + if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { + return false; + } + this->want_disconnect_ = false; + return true; +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + switch (this->search_state_) { + case SearchState::PRESTARTED: + // The pending SEARCH_CMPL reports once it lands. + this->search_state_ = SearchState::CLAIMED; + return 0; + case SearchState::PRESTART_DONE: + // Already landed: the flush after the connected report delivers + // (loop() covers a claim made outside that event drain). + this->search_state_ = SearchState::REPORT_PENDING; + this->enable_loop(); + return 0; + case SearchState::CLAIMED: + case SearchState::REPORT_PENDING: + return 0; // One completion is already owed to this claimant. + case SearchState::NONE: + break; + } + int err = this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr)); + if (err == 0) { + this->search_state_ = SearchState::CLAIMED; + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, + handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // The BTC layer copies the payload immediately, so the const_cast is safe. + return this->check_and_log_error_( + "esp_ble_gattc_write_char", + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::read_descriptor(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_read_char_descr", + esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_write_char_descr", + esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // Local registration only; the CCCD write is the API client's responsibility. + if (enable) { + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", + esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle)); + } + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", + esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle)); +} + +int BluedroidGattClient::pair() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); +} + +int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); +} + +void BluedroidGattClient::release_services() { + this->service_total_ = 0; + // Always set: terminates any in-flight stream on every cache config. + this->services_released_ = true; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // A failed clean leaves a stale database the next connection could serve + // as authoritative. A disabled stack invalidates its own cache; skip the + // meaningless call instead of warning on every OTA/ble.disable teardown. + if (esp32_ble::global_ble->is_active()) { + this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_)); + } +#endif +} + +// ---- internals ---- + +bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { + return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; +} + +void BluedroidGattClient::set_idle_() { + this->set_state(ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + this->search_state_ = SearchState::NONE; + this->search_status_ = 0; +} + +void BluedroidGattClient::set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(ClientState::DISCONNECTING); + // The loop may be disabled while idle; the safety timeout needs it. + this->enable_loop(); +} + +esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { + 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 = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type); + return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params)); +} + +int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_gattc_warning_(operation, err); + } + return err; +} + +void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) { + ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code); +} + +// ---- service streaming ---- + +int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + if (status != ESP_GATT_OK) { + // A failed discovery reads as a clean zero from the count calls below; + // honoring the event status stops it becoming an authoritative empty + // list. + return status; + } + uint16_t primary = 0; + uint16_t secondary = 0; + auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE, + 0x0001, 0xFFFF, 0, &primary); + auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, + 0x0001, 0xFFFF, 0, &secondary); + if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) { + // A failed count must not become an authoritative empty database. + auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status); + return count_status; + } + this->service_total_ = primary + secondary; + return 0; +} + +// Reports a completed search once claimed; delivery consumes the state so +// a re-discovery issues a real search. +void BluedroidGattClient::deliver_pending_search_() { + if (this->search_state_ != SearchState::REPORT_PENDING) + return; + this->search_state_ = SearchState::NONE; + this->listener_->on_service_discovery_done(this->search_status_); +} + +#ifdef USE_BLUETOOTH_PROXY +// The wrapper's compile-time streamer detection must keep finding this +// method; a signature drift would silently fall back to the table streamer, +// which proxy builds compile without a materializer. +static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); + +void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { + if (this->services_released_) { + // Released under the stream: park without services-done so a partial + // list is never cached as authoritative (the client retries after its + // GetServices timeout). + ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + return; + } + if (conn.send_service_ >= this->service_total_) { + conn.send_service_ = DONE_SENDING_SERVICES; + conn.proxy_->send_gatt_services_done(conn.address_); + this->release_services(); + return; + } + + // The subscriber vanished mid-stream: park the cursor at done WITHOUT + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list). + auto *api_conn = conn.proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + this->release_services(); + return; + } + + bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids(); + api::BluetoothGATTGetServicesResponse resp; + resp.address = conn.address_; + size_t current_size = resp.calculate_size(); + int16_t batch_start = conn.send_service_; + + while (conn.send_service_ < this->service_total_) { + esp_gattc_service_elem_t service_result; + uint16_t svc_count = 1; + esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, + &svc_count, conn.send_service_); + if (svc_status != ESP_GATT_OK || svc_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_, + conn.address_str_, conn.send_service_); + conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND); + return; + } + uint16_t total_char_count = 0; + auto char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); + if (char_count_status != ESP_GATT_OK) { + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status); + conn.abort_service_stream(char_count_status); + return; + } + + // If this service likely won't fit, send the current batch 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) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); + service_resp.handle = service_result.start_handle; + + if (total_char_count > 0) { + service_resp.characteristics.init(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + // Bounded by the count query: a misbehaving peripheral can make the + // enumeration return more entries than it reported. + while (char_offset < total_char_count) { + uint16_t cc = 1; + auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (char_status != ESP_GATT_OK || cc == 0) { + // An early terminator contradicts the count from the same cache; + // never stream a silently truncated list. + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); + conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND); + return; + } + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + + uint16_t total_desc_count = 0; + auto 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) { + // Abort rather than stream the characteristic descriptor-less: a + // missing CCCD in a cached database breaks notifications for good. + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status); + conn.abort_service_stream(desc_count_status); + return; + } + if (total_desc_count > 0) { + characteristic_resp.descriptors.init(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (desc_offset < total_desc_count) { + uint16_t dc = 1; + auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle, + &desc_result, &dc, desc_offset); + if (desc_status != ESP_GATT_OK || dc == 0) { + this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status); + conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND); + return; + } + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } + } + char_offset++; + } + } + + if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // On a failed send, rewind the cursor so the batch is retried instead of + // silently skipped. + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_); + conn.send_service_ = batch_start; + } +} +#endif // USE_BLUETOOTH_PROXY + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + auto st = this->state(); + if (st == ClientState::IDLE) { + // Late OPEN_EVT after the slot went IDLE (open-error race, or the + // teardown net gave up): close a won link, never resurrect the slot. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status); + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + // A failed close here leaks a live link nothing tracks; make it heard. + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + } + return; + } + if (st != ClientState::CONNECTING) { + ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_); + } + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + this->log_gattc_warning_("Connection open", param->open.status); + // Never established, CLOSE_EVT may not follow. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, param->open.status); + return; + } + if (this->disconnect_pending()) { + // Open resolved with a teardown scheduled: close now (conn_id_ stays set + // so CLOSE_EVT still matches). + this->unconditional_disconnect_(); + return; + } + this->set_state(ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_); + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + this->set_state(ClientState::ESTABLISHED); + // No discovery phase: report immediately with the default MTU. The + // cached path never waits for (or reports) the exchange - seen_mtu_ + // suppresses the CFG_MTU report, matching the previous esp32 behavior. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + } else { + // Discovery-bound connection: start the search now so it overlaps the + // MTU exchange. On a refusal fall back to the serialized path - the + // consumer's own discover_services() call retries the real search. + if (this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) { + this->search_state_ = SearchState::PRESTARTED; + } + if (this->mtu_failed_ && !this->seen_mtu_) { + // Refused MTU request: report with the default so the consumer + // proceeds. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + this->deliver_pending_search_(); + } + } +} + +void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) { + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_); + } else { + ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason); + } + if (this->state() == ClientState::IDLE) { + // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. + return; + } + // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting + // earlier makes the controller reject with 133 or assert) and before + // reporting - the wrapper frees the slot on the report, and a freed slot + // invites a reconnect into the still-closing link. + this->release_services(); + this->set_disconnecting_(); +} + +bool BluedroidGattClient::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) + return false; + if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) + return false; + + switch (event) { + case ESP_GATTC_REG_EVT: { + if (param->reg.status == ESP_GATT_OK) { + this->gattc_if_ = esp_gattc_if; + } else { + ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); + this->mark_failed(); + } + break; + } + case ESP_GATTC_CONNECT_EVT: { + if (!this->check_addr_(param->connect.remote_bda)) + return false; + this->conn_id_ = param->connect.conn_id; + // MTU request here rather than OPEN_EVT, matching the IDF examples. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); + // No CFG_MTU_EVT will follow; OPEN_EVT reports with the default. + this->mtu_failed_ = true; + } + break; + } + case ESP_GATTC_OPEN_EVT: { + if (!this->check_addr_(param->open.remote_bda)) + return false; + this->handle_open_evt_(param); + break; + } + case ESP_GATTC_CFG_MTU_EVT: { + if (this->conn_id_ != param->cfg_mtu.conn_id) + return false; + if (param->cfg_mtu.status != ESP_GATT_OK) { + // Warn only; a disconnect will follow if the link is dead. + this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status); + } + if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) { + // Teardown owns the link: suppress the connected report here like + // OPEN_EVT and SEARCH_CMPL do; the terminal report settles it. + this->seen_mtu_ = true; + // The connected report waited for the MTU; forwarded, not stored. + this->listener_->on_connection_state( + true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0); + // The consumer requests discovery from inside that report; when the + // pre-started search already finished, complete it in the same drain. + this->deliver_pending_search_(); + } + break; + } + case ESP_GATTC_DISCONNECT_EVT: { + if (!this->check_addr_(param->disconnect.remote_bda)) + return false; + this->handle_disconnect_evt_(param); + break; + } + case ESP_GATTC_CLOSE_EVT: { + if (this->conn_id_ != param->close.conn_id) + return false; + this->release_services(); + this->set_idle_(); + // The one connected=false report: the wrapper frees the slot on it, + // so it must not fire before the controller finished closing. + this->listener_->on_connection_state(false, 0, param->close.reason); + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->conn_id_ != param->search_cmpl.conn_id) + return false; + ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); + if (this->state() == ClientState::DISCONNECTING) { + // Teardown owns the link; the result is never delivered, skip the + // work. + break; + } + this->search_status_ = this->handle_search_cmpl_(static_cast(param->search_cmpl.status)); + this->search_state_ = + this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE; + this->set_state(ClientState::ESTABLISHED); + this->deliver_pending_search_(); + break; + } + case ESP_GATTC_READ_CHAR_EVT: + case ESP_GATTC_READ_DESCR_EVT: { + if (this->conn_id_ != param->read.conn_id) + return false; + bool ok = param->read.status == ESP_GATT_OK; + this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr, + ok ? param->read.value_len : 0, ok ? 0 : param->read.status); + break; + } + case ESP_GATTC_WRITE_CHAR_EVT: + case ESP_GATTC_WRITE_DESCR_EVT: { + if (this->conn_id_ != param->write.conn_id) + return false; + this->listener_->on_write_result(param->write.handle, + param->write.status == ESP_GATT_OK ? 0 : param->write.status); + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state(param->reg_for_notify.handle, true, + param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); + break; + } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state( + param->unreg_for_notify.handle, false, + param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (this->conn_id_ != param->notify.conn_id) + return false; + ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle); + this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); + break; + } + default: + break; + } + return true; +} + +void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + // Always accept; a refused response means no AUTH_CMPL, so answer the + // pairing request with the failure. + int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp", + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true)); + if (sec_err != 0) { + this->listener_->on_pairing_result(sec_err); + } + break; + } + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + this->listener_->on_pairing_result( + param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); + break; + } + default: + break; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..19b89ea5cd --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,142 @@ +// Bluedroid (esp32) GATT client backend: the esp32 arm of the +// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection +// wrapper. Not a BLEClientBase: the tracker's promote loop owns +// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only +// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in +// the tracker-invoked connect() override. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::bluetooth_connection { + +#ifdef USE_BLUETOOTH_PROXY +class BluetoothConnection; +#endif + +// One class carries both halves: the tracker's ESPBTClient surface (its +// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the +// virtual connect()/disconnect()) and the neutral contract ops. The +// contract's teardown op is named gatt_disconnect() because the tracker's +// void disconnect() cannot overload with an int-returning twin. +class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { + public: + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; + + // Lifecycle of one connection attempt's service search. + enum class SearchState : uint8_t { + NONE, // no search this attempt + PRESTARTED, // issued at OPEN_EVT, no claimant yet + PRESTART_DONE, // completed with search_status_ latched, no claimant yet + CLAIMED, // in flight with a claimant (pre-started or direct) + REPORT_PENDING // completed and claimed: deliver on the next flush + }; + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + // Wired by codegen before setup and invariant for the device lifetime. + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- esp32_ble_tracker::ESPBTClient ---- + bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void connect() override; + void disconnect() override; + bool wants_parsed_advertisements() override { return false; } + void on_scan_end() override {} + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + bool cancel_gatt_disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + // Contract stub: the proxy streams in place; the on-demand materializer + // for direct consumers lands with #18205. NOTE: a direct consumer reaching + // this stub gets an empty table indistinguishable from a service-less + // peer - do not ship one against this backend before the materializer. + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services(); + +#ifdef USE_BLUETOOTH_PROXY + /// In-place service streamer (the proxy wrapper detects and prefers it): + /// builds one api response batch directly from Bluedroid's cached database, + /// so the streaming peak is the response itself - the old esp32 model. + void stream_service_batch(BluetoothConnection &conn); +#endif + + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + + protected: + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + int handle_search_cmpl_(esp_gatt_status_t status); + void deliver_pending_search_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + int check_and_log_error_(const char *operation, esp_err_t err); + void log_gattc_warning_(const char *operation, int code); + + // Group 1: pointers / composed objects + ble_device_base::GattClientListener *listener_{nullptr}; + // Group 2: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 3: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 4: 2-byte types + uint16_t conn_id_{UNSET_CONN_ID}; + uint16_t service_total_{0}; + + // Group 5: 1-byte types + esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes + // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. + uint8_t remote_addr_type_{0}; + esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE}; + uint8_t connection_index_{0}; + // Terminates an in-flight stream (never send a partial list as authoritative) + // and marks a cleaned cache unsafe to walk (Bluedroid asserts). + bool services_released_ : 1 {false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_ : 1 {false}; + // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. + bool mtu_failed_ : 1 {false}; + // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() + // completes from it. Reset by set_idle_(). + static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); + SearchState search_state_ : 4 {SearchState::NONE}; + // esp_gatt_status_t of the completed search, held until claimed. + uint8_t search_status_{0}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp deleted file mode 100644 index f5c59ca43a..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ /dev/null @@ -1,484 +0,0 @@ -#include "bluetooth_connection_esp32.h" - -#include "esphome/components/api/api_pb2.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" - -namespace esphome::bluetooth_connection { - -namespace espbt = esphome::esp32_ble_tracker; - -using ble_device_base::ESPBTUUID; - -static const char *const TAG = "bluetooth_connection"; - -conn_err_t unpair_device(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_remove_bond_device(bd_addr); -} - -conn_err_t clear_gatt_cache(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_gattc_cache_clean(bd_addr); -} - -void BluetoothConnection::dump_config() { - ESP_LOGCONFIG(TAG, "BLE Connection:"); - BLEClientBase::dump_config(); -} - -void BluetoothConnection::set_address(uint64_t address) { - // Keep the proxy's pre-allocated connections-free message in step - this->proxy_->update_address_slot_(this->address_, address); - // Call parent implementation to actually set the address - BLEClientBase::set_address(address); -} - -void BluetoothConnection::loop() { - BLEClientBase::loop(); - - // Early return if no active connection - if (this->address_ == 0) { - return; - } - - // Handle service discovery if in valid range - if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { - this->send_service_for_discovery_(); - } - - // Check if we should disable the loop - // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete - // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the - // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. - if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && - (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { - this->disable_loop(); - } -} - -void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { - // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the - // base class. Free the proxy slot, notify the API client, and reset send_service_. - // address_ may already be 0 if reset_connection_ ran earlier on this teardown. - if (this->address_ == 0) { - return; - } - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); - this->reset_connection_(reason); -} - -void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } - -void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ >= this->service_count_) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); - this->release_services(); - return; - } - - // Early return if no API connection - auto *api_conn = this->proxy_->get_api_connection(); - if (api_conn == nullptr) { - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); - - // Prepare response - api::BluetoothGATTGetServicesResponse resp; - resp.address = this->address_; - - // Dynamic batching based on actual size - // Keep running total of actual message size - size_t current_size = resp.calculate_size(); - int16_t batch_start = this->send_service_; - - 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, - &service_result, &service_count, this->send_service_); - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // 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, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); - - if (char_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // 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; - } - - // Now add the service since we know it will likely fit - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid), - use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - if (total_char_count > 0) { - // Initialize FixedVector with exact count and process characteristics - service_resp.characteristics.init(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - // Bound by total_char_count: the vector is sized for it, and a malicious peripheral - // can make enumeration return more entries than the count query reported - while (char_offset < total_char_count) { // 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) { - this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid), - use_efficient_uuids); - 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) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - continue; - } - - // Initialize FixedVector with exact count and process descriptors - characteristic_resp.descriptors.init(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (desc_offset < total_desc_count) { // 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) { - this->log_connection_error_("esp_ble_gattc_get_all_descr", 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, ESPBTUUID::from_uuid(desc_result.uuid), - use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } - } - } // end if (total_char_count > 0) - - if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) != - BatchClose::CONTINUE) { - break; - } - } - - // Send the message with dynamically batched services; on a failed send, - // rewind the cursor so the batch is retried instead of silently skipped. - if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); - this->send_service_ = batch_start; - } -} - -void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); -} - -void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); -} - -void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, - type); -} - -void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), - operation, handle, status); -} - -esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { - if (err != ESP_OK) { - this->log_connection_warning_(operation, err); - return err; - } - return ESP_OK; -} - -bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) - return false; - - switch (event) { - case ESP_GATTC_DISCONNECT_EVT: { - // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources - // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, - param->disconnect.reason); - // Send disconnection notification but don't free the slot yet - this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); - break; - } - case ESP_GATTC_OPEN_EVT: { - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - this->reset_connection_(param->open.status); - } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - } - this->seen_mtu_or_services_ = false; - break; - } - case ESP_GATTC_CFG_MTU_EVT: - case ESP_GATTC_SEARCH_CMPL_EVT: { - if (!this->seen_mtu_or_services_) { - // We don't know if we will get the MTU or the services first, so - // only send the device connection true if we have already received - // the services. - this->seen_mtu_or_services_ = true; - break; - } - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - break; - } - case ESP_GATTC_READ_DESCR_EVT: - case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); - this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTReadResponse resp; - resp.address = this->address_; - resp.handle = param->read.handle; - resp.set_data(param->read.value, param->read.value_len); - api_connection->send_message(resp); - break; - } - case ESP_GATTC_WRITE_CHAR_EVT: - case ESP_GATTC_WRITE_DESCR_EVT: { - if (param->write.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); - this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = param->write.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (param->unreg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, - param->unreg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->unreg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - if (param->reg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, - param->reg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->reg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, - param->notify.handle); - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyDataResponse resp; - resp.address = this->address_; - resp.handle = param->notify.handle; - resp.set_data(param->notify.value, param->notify.value_len); - api_connection->send_message(resp); - break; - } - default: - break; - } - return true; -} - -void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - BLEClientBase::gap_event_handler(event, param); - - switch (event) { - case ESP_GAP_BLE_AUTH_CMPL_EVT: - if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0) - break; - if (param->ble_security.auth_cmpl.success) { - this->proxy_->send_device_pairing(this->address_, true); - } else { - this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason); - } - break; - default: - break; - } -} - -esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "characteristic"); - return GATT_NOT_CONNECTED; - } - - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char", err); -} - -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, - bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "characteristic"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char", err); -} - -esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "descriptor"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); -} - -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "descriptor"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); -} - -esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { - if (!this->connected()) { - this->log_gatt_not_connected_("notify", "characteristic"); - return GATT_NOT_CONNECTED; - } - - if (enable) { - ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); - } - - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); -} - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h deleted file mode 100644 index fb60d93e9c..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/components/esp32_ble_client/ble_client_base.h" - -#include "bluetooth_connection.h" - -namespace esphome::bluetooth_proxy { -class BluetoothProxy; -} // namespace esphome::bluetooth_proxy - -namespace esphome::bluetooth_connection { - -class BluetoothConnection final : public esp32_ble_client::BLEClientBase { - public: - void dump_config() override; - void loop() override; - bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - // The proxy's connections never consume parsed ESPBTDevice objects. - bool wants_parsed_advertisements() override { return false; } - - esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); - esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); - - esp_err_t notify_characteristic(uint16_t handle, bool enable); - - esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { - return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); - } - - bool has_gatt_services() const { return this->service_count_ != 0; } - - /// Start connecting: record the API address type and hand the client to the - /// tracker's promote loop (it pauses the scan and opens the connection). - void initiate_connection(uint8_t address_type) { - this->set_remote_addr_type(static_cast(address_type)); - this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); - } - - void set_address(uint64_t address) override; - - protected: - friend class bluetooth_proxy::BluetoothProxy; - - void on_disconnect_complete(esp_err_t reason) override; - - void send_service_for_discovery_(); - void reset_connection_(esp_err_t reason); - void log_connection_error_(const char *operation, esp_gatt_status_t status); - void log_connection_warning_(const char *operation, esp_err_t err); - void log_gatt_not_connected_(const char *action, const char *type); - void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); - esp_err_t check_and_log_error_(const char *operation, esp_err_t err); - - // Memory optimized layout for 32-bit systems - // Group 1: Pointers (4 bytes each, naturally aligned) - bluetooth_proxy::BluetoothProxy *proxy_; - - // Group 2: 2-byte types - int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states - - // Group 3: 1-byte types - bool seen_mtu_or_services_{false}; - // 1 byte used, 1 byte padding -}; - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index d8792b88c1..3c982d81ae 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -15,19 +15,21 @@ #if defined(USE_RP2040_BLE) #include "bluetooth_connection_rp2.h" #define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_ESP32_BLE) +#include "bluetooth_connection_bluedroid.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient #elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) // Emitted only by the host unit-test manifest: the tests compile the hub // wrapper standalone, so bind a do-nothing backend. Every other backend-less // build hits the #error below. namespace esphome::bluetooth_connection { -class BluetoothConnection; - class StubGattBackend { public: - void set_listener(BluetoothConnection *listener) {} + void set_listener(ble_device_base::GattClientListener *listener) {} int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } - int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + bool cancel_gatt_disconnect() { return false; } int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { @@ -43,6 +45,7 @@ class StubGattBackend { return ble_device_base::GATT_ERR_NOT_CONNECTED; } ble_device_base::GattServiceTable get_service_table() { return {}; } + void set_connection_type(ble_device_base::ConnectionType ct) {} void release_services() {} }; @@ -55,7 +58,7 @@ class StubGattBackend { namespace esphome::ble_device_base { using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); #undef ESPHOME_BLE_GATT_CONNECTION_TYPE diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index ec03f18e1d..b913bb9a55 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ -// Hub-platform connection wrapper (USE_RP2 hub builds today). +// The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +#ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -26,11 +26,11 @@ void BluetoothConnection::set_address(uint64_t address) { format_mac_addr_upper(mac, this->address_str_); } -void BluetoothConnection::start_connect_() { - // No connect timeout here (esp32 parity): the client's own timeout or - // the api-gone sweep drives disconnect(). +void BluetoothConnection::initiate_connection(uint8_t address_type) { + // No connect timeout here: the API client's own timeout or the api-gone + // sweep drives disconnect(). this->state_ = ClientState::CONNECTING; - int err = this->backend_->connect(this->address_, this->remote_addr_type_); + int err = this->backend_->connect(this->address_, address_type); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); @@ -38,40 +38,21 @@ void BluetoothConnection::start_connect_() { } void BluetoothConnection::disconnect() { - // Idempotent like the esp32 class: the proxy's teardown loop calls this - // every 100 ms while the API subscriber is gone, and a repeat call must not - // reach the backend (whose busy error would free the slot mid-teardown). + // Idempotent: the proxy's teardown loop calls this every 100 ms while the + // API subscriber is gone, and a repeat call reaching the backend would + // re-arm its teardown timer so the safety timeout never fires. if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { return; } - int err = this->backend_->disconnect(); - if (err == GATT_NOT_CONNECTED) { - // Backend already idle: free the slot so the client is not stuck. - ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_); + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nonzero means nothing to tear down (both backends): free the slot. + // Accepted teardowns always reach a terminal report. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); return; } - if (err != 0) { - // Transient refusal: stay DISCONNECTING and let the safety timeout - // arbitrate rather than freeing a slot whose teardown is unresolved. - // Latch the refusal unless a GATT cause is already recorded (first wins). - ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err); - if (this->pending_error_ == 0) { - this->pending_error_ = err; - } - } this->state_ = ClientState::DISCONNECTING; - this->disconnecting_started_ = millis(); -} - -void BluetoothConnection::check_disconnect_timeout_() { - // Safety net mirroring the esp32 base class: if the backend's disconnect - // completion is lost, force the slot free instead of leaking it. - static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; - if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); - this->reset_connection_(GATT_NOT_CONNECTED); - } } void BluetoothConnection::on_pairing_result(int status) { @@ -96,32 +77,24 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- backend event sink ---- +// ---- backend event listener ---- void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { if (connected && this->address_ == 0) { // Late completion for a slot that was already freed: nothing to report, // and the api-gone sweep or a new reservation owns the slot now. - int err = this->backend_->disconnect(); - if (err != 0 && err != GATT_NOT_CONNECTED) { - // Log only: re-arming a freed slot could clobber a new reservation. - ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err); - } + // Return ignored: nonzero just means the backend was already idle, and + // re-arming a freed slot could clobber a new reservation. + this->backend_->gatt_disconnect(); return; } if (connected && this->state_ == ClientState::DISCONNECTING) { // The link came up after a disconnect request won the race; finish the // teardown instead of reporting a connection the client no longer wants. - int err = this->backend_->disconnect(); - // Fresh teardown attempt: give it the full safety window. - this->disconnecting_started_ = millis(); - if (err == GATT_NOT_CONNECTED) { + int err = this->backend_->gatt_disconnect(); + if (err != 0) { // Nothing left to tear down after all. this->reset_connection_(err); - } else if (err != 0) { - // Transient refusal while the link is up: keep DISCONNECTING and let - // the safety timeout arbitrate (same policy as disconnect()). - ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err); } return; } @@ -130,7 +103,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters (same lifecycle place as esp32). + // shared steady-state parameters. On esp32 the backend already set the + // same values as prefer-params before opening, so this request is + // usually redundant there - kept because rp2 has no prefer-params and + // the explicit update is its only path to the steady-state interval. this->state_ = ClientState::ESTABLISHED; int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, @@ -145,14 +121,13 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int return; } // V3_WITHOUT_CACHE: discover services first — the connected response is - // sent when discovery completes, mirroring the esp32 flow (MTU + services - // before the response). + // sent when discovery completes (MTU + services before the response). this->state_ = ClientState::CONNECTED; int err = this->backend_->discover_services(); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); // Latch the real cause for the disconnect report. - this->pending_error_ = err; + this->latch_pending_error_(err); this->disconnect(); } return; @@ -171,7 +146,7 @@ void BluetoothConnection::on_service_discovery_done(int error) { ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); // Carry the GATT error into the disconnection report so the client sees // the real cause instead of a generic HCI reason. - this->pending_error_ = error; + this->latch_pending_error_(error); this->disconnect(); return; } @@ -334,9 +309,9 @@ void BluetoothConnection::send_service_for_discovery_() { } // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (esp32 parity — a resubscribing client gets - // silence and its 30 s timeout, never an authoritative partial list) and - // free the table; the api-gone sweep tears the connection down anyway. + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list) and free the table; the + // api-gone sweep tears the connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, @@ -380,8 +355,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", this->connection_index_, this->address_str_, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - this->disconnect(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (char_count > 0) { @@ -397,8 +371,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", this->connection_index_, this->address_str_, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - this->disconnect(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (desc_count == 0) { @@ -433,4 +406,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index e79ee9e7a8..82d9ae7db4 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,17 +1,17 @@ -// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// BluetoothConnection: drives the build's GATT backend (the // ble_device_base::BLEGattConnection alias) and translates its events into -// the same API messages the esp32 class emits. -// Presents the identical method surface, so the proxy's GATT dispatch -// compiles against either class unchanged. +// the proxy's API messages. One wrapper for every platform; per-backend +// differences live behind the alias and the streamer cut-through. #pragma once -#include "esphome/core/defines.h" - -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) - #include "bluetooth_connection.h" +// The wrapper exists to serve the proxy's API surface; direct consumers +// drive the backend themselves, so backend-only builds compile this header +// empty. +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final { +class BluetoothConnection final : public ble_device_base::GattClientListener { public: /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { @@ -33,7 +33,7 @@ class BluetoothConnection final { backend->set_listener(this); } - // ---- proxy dispatch surface (mirrors the esp32 class) ---- + // ---- proxy dispatch surface ---- conn_err_t read_characteristic(uint16_t handle); conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); conn_err_t read_descriptor(uint16_t handle); @@ -41,21 +41,31 @@ class BluetoothConnection final { conn_err_t notify_characteristic(uint16_t handle, bool enable); conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); - /// Start connecting: record the API address type (BLE_ADDR_TYPE_* code - /// space) and open the connection through the backend. Failures report - /// through the same reset path a failed open takes on esp32. - void initiate_connection(uint8_t address_type) { - this->remote_addr_type_ = address_type; - this->start_connect_(); + /// Streamer abort: latch the GATT cause, park the cursor, tear down. + void abort_service_stream(conn_err_t err) { + this->latch_pending_error_(err); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); } + + /// Start connecting with the API address type (BLE_ADDR_TYPE_* code + /// space). Failures report through the same reset path a failed open + /// takes. + void initiate_connection(uint8_t address_type); void disconnect(); + /// A connect request racing a scheduled teardown: true when the backend + /// had not started closing - the in-flight open resumes and reports + /// connected. False once the teardown owns the link. + bool cancel_teardown() { + if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) { + this->state_ = ClientState::CONNECTING; + return true; + } + return false; + } bool is_paired() const { return this->paired_; } void set_unpaired() { this->paired_ = false; } conn_err_t pair() { return this->backend_->pair(); } - // A backend disconnect() is a single call that also cancels an in-progress - // connect; there is no deferred-disconnect state to track. - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} void set_address(uint64_t address); uint64_t get_address() const { return this->address_; } @@ -65,39 +75,58 @@ class BluetoothConnection final { ClientState state() const { return this->state_; } void set_state(ClientState st) { this->state_ = st; } bool connected() const { return this->state_ == ClientState::ESTABLISHED; } - void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } + void set_connection_type(ConnectionType ct) { + this->connection_type_ = ct; + // The bluedroid backend branches on the type itself (prefer-params and + // the with-cache report at OPEN_EVT); the others ignore it. + this->backend_->set_connection_type(ct); + } // Latched at discovery completion rather than read from the backend table: // streaming frees the table, and this must stay true for the connection's - // lifetime (esp32 parity — a repeat GetServices is silently ignored there, - // never answered with an authoritative empty database). + // lifetime (a repeat GetServices is silently ignored, never answered with + // an authoritative empty database). bool has_gatt_services() const { return this->services_discovered_; } - /// Stream any pending service-discovery batch and police the disconnect - /// safety timeout. Called from the proxy's loop — hub connections have no - /// Component loop of their own (the esp32 class streams from its own - /// loop() and has the same 10 s safety net in its base class). + /// Stream any pending service-discovery batch (proxy loop; the backend + /// owns the disconnect safety timer). void process_pending_services() { if (this->send_service_ >= 0) { - this->send_service_for_discovery_(); + this->stream_pending_(this->backend_); } - this->check_disconnect_timeout_(); } - // ---- backend event sink (called directly by the backend, main loop) ---- - void on_connection_state(bool connected, uint16_t mtu, int error); - void on_service_discovery_done(int error); - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); - void on_write_result(uint16_t handle, int error); - void on_notify_state(uint16_t handle, bool enabled, int error); - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); - void on_pairing_result(int status); + // ---- backend event listener (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; protected: friend class bluetooth_proxy::BluetoothProxy; + // The Bluedroid backend streams services in place from its stack cache. + friend class BluedroidGattClient; - void start_connect_(); + /// First cause wins: a later, less specific error must not overwrite it. + void latch_pending_error_(conn_err_t err) { + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + // A backend providing its own streamer (see the contract doc) builds the + // response in place from its stack cache; the rest use the table streamer. + // Template so the discarded branch is not odr-checked against backends + // that lack the method. + template void stream_pending_(Backend *backend) { + if constexpr (requires { backend->stream_service_batch(*this); }) { + backend->stream_service_batch(*this); + } else { + this->send_service_for_discovery_(); + } + } void send_service_for_discovery_(); - void check_disconnect_timeout_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -109,28 +138,26 @@ class BluetoothConnection final { // Group 2: 2-byte types int16_t send_service_{INIT_SENDING_SERVICES}; - uint16_t mtu_{23}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // Group 3: 8-byte and 4-byte types uint64_t address_{0}; - uint32_t disconnecting_started_{0}; conn_err_t pending_error_{0}; // Group 4: Arrays char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; - // Group 5: 1-byte types - ClientState state_{ClientState::IDLE}; - bool paired_{false}; - ConnectionType connection_type_{ConnectionType::V1}; - uint8_t remote_addr_type_{0}; - uint8_t connection_index_{0}; - bool services_discovered_{false}; + // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); + static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), + "connection_type_ bitfield too narrow"); + ClientState state_ : 3 {ClientState::IDLE}; + bool paired_ : 1 {false}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + uint8_t connection_index_ : 4 {0}; + bool services_discovered_ : 1 {false}; }; -static_assert(ble_device_base::GattClientEventSinkContract, - "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); - } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc730659f5..dc77d448a5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection_rp2.h" -#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -26,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; -static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -384,7 +382,7 @@ void RP2GattClient::loop() { RP2GattNotifyEvent *notify; while ((notify = this->notify_queue_.pop()) != nullptr) { - if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + if (this->notify_subscribed_(notify->handle)) { this->listener_->on_notify_data(notify->handle, notify->data, notify->len); } this->notify_pool_.release(notify); @@ -395,7 +393,7 @@ void RP2GattClient::loop() { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); - this->disconnect(); + this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { @@ -426,11 +424,11 @@ void RP2GattClient::loop() { // reclaims state if the disconnection event is lost. Dropping engine // state without gap_disconnect would leak the live link and the // single GATT slot for the rest of the boot. - this->disconnect(); + this->gatt_disconnect(); } } } else if (this->state_ == EngineState::DISCONNECTING) { - if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } @@ -448,9 +446,7 @@ void RP2GattClient::loop() { } if (timed_out) { ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); - } + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && this->event_queue_.empty() && this->notify_queue_.empty())) { @@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(true, this->mtu_, 0); - } + this->listener_->on_connection_state(true, this->mtu_, 0); } break; case RP2GattEvent::QUERY_COMPLETE: @@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->finish_write_no_rsp_(event.status); break; case RP2GattEvent::PAIRING_RESULT: - if (this->listener_ != nullptr) { - this->listener_->on_pairing_result(event.status); - } + this->listener_->on_pairing_result(event.status); break; } } @@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) { return; } this->op_type_ = OpType::NONE; - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, status); - } + this->listener_->on_write_result(this->op_handle_, status); } void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { @@ -576,9 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) { this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(false, 0, reason); - } + this->listener_->on_connection_state(false, 0, reason); } void RP2GattClient::cleanup_link_state_() { @@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; - if (this->listener_ == nullptr) { - return; - } switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: @@ -796,9 +781,7 @@ void RP2GattClient::finish_discovery_(int error) { if (error != 0) { this->release_services(); } - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(error); - } + this->listener_->on_service_discovery_done(error); } ble_device_base::GattServiceTable RP2GattClient::get_service_table() { @@ -873,7 +856,7 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { return 0; } -int RP2GattClient::disconnect() { +int RP2GattClient::gatt_disconnect() { switch (this->state_) { case EngineState::IDLE: return GATT_ERR_NOT_CONNECTED; @@ -990,7 +973,7 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui return 0; } } - if (status == 0 && this->listener_ != nullptr) { + if (status == 0) { this->listener_->on_write_result(handle, 0); } return status; @@ -1092,9 +1075,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { } } } - if (this->listener_ != nullptr) { - this->listener_->on_notify_state(handle, enable, 0); - } + this->listener_->on_notify_state(handle, enable, 0); return 0; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index d5bf76e6ee..df43ebd66d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -26,8 +26,6 @@ namespace esphome::bluetooth_connection { -class BluetoothConnection; - // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -80,11 +78,14 @@ class RP2GattClient final : public Component, public Parentedlistener_ = listener; } + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } // ---- ble_device_base::BLEGattConnection contract ---- int connect(uint64_t address, uint8_t addr_type); - int disconnect(); + int gatt_disconnect(); + // Teardown starts inside gatt_disconnect() on this backend; nothing is + // ever scheduled, so there is nothing to cancel. + bool cancel_gatt_disconnect() { return false; } int discover_services(); int read_characteristic(uint16_t handle); int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); @@ -94,6 +95,8 @@ class RP2GattClient final : public Component, public Parented event_queue_; esphome::EventPool event_pool_; @@ -174,7 +177,7 @@ class RP2GattClient final : public Component, public Parented list[str]: @@ -27,7 +33,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: target platform set, so it takes one of the concrete branches. """ if CORE.is_esp32: - return ["bluetooth_connection", "esp32_ble_client", "esp32_ble_tracker"] + return ["bluetooth_connection", "esp32_ble_tracker"] if CORE.target_platform in _HUB_PLATFORMS: return ["ble_device_base", "bluetooth_connection"] # No target platform, or one this component does not support: tooling @@ -36,7 +42,6 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: return [ "ble_device_base", "bluetooth_connection", - "esp32_ble_client", "esp32_ble_tracker", ] @@ -47,8 +52,9 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Assistant) assumes an ESPHome proxy can scan actively, so a passive-only # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). -# Coupled to bluetooth_connection: platforms with a GATT backend are also -# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry. +# Coupled to bluetooth_connection: platforms here are also listed in its +# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES +# hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -59,7 +65,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" -CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") @@ -86,12 +91,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - BluetoothConnection = bluetooth_connection.esp32_connection_class() - CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } - ).extend(cv.COMPONENT_SCHEMA) + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32) def validate_connections(config): if CONF_CONNECTIONS in config: @@ -154,16 +154,7 @@ def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack GATT client backend in bluetooth_connection. The slot limit comes from the prebuilt BTstack library (one connection today); the code is built for N.""" - from esphome.components import rp2040_ble - - connection_schema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.RP2GattClient - ), - } - ) + connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) def populate_connections(config: ConfigType) -> ConfigType: # One wrapper + backend pair per slot, declared during validation so @@ -182,11 +173,6 @@ def _rp2_config_schema() -> cv.All: cv.Schema( { **_COMMON_SCHEMA_KEYS, - # The GATT backend drives the controller directly (connect, GATT - # ops), not through the tracker hub. - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, @@ -212,25 +198,25 @@ def _rp2_config_schema() -> cv.All: return cv.All(schema, populate_connections) -async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble - - # One wrapper + backend pair per slot (the esp32 arm's pattern). - for connection_conf in config[CONF_CONNECTIONS]: - ble_device_base.request_gatt_client() - backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) - await cg.register_component(backend, connection_conf) - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) +async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + """One wrapper + backend pair per slot; the platform-specific backend + registration lives in bluetooth_connection.new_gatt_backend().""" + connections = config.get(CONF_CONNECTIONS, []) + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present (zero on advertisement-only + # hubs); sized here so it can never diverge from the loop below. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + for connection_conf in connections: + backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) -# Per-platform schema builders and connection codegen; every key of -# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by -# tests/component_tests/bluetooth_proxy/). +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. _GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} -_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} # Keys every platform arm declares identically; each arm spreads this dict so @@ -381,15 +367,7 @@ async def _to_code_esp32(config: ConfigType) -> None: # registration into the proxy; the other hubs are polled instead. cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") - # 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) - cg.add(var.register_connection(connection_var)) - await esp32_ble_tracker.register_raw_client(connection_var, connection_conf) + await _connections_to_code(var, config) if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) @@ -403,16 +381,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) cg.add(var.set_ble_hub(hub)) - # The api component sizes BluetoothConnectionsFreeResponse.allocated with - # this define whenever a proxy is present. Zero on advertisement-only hubs. - # Sized from the instantiated connections so the define can never diverge - # from the loop below (the define sizes fixed storage in the proxy). - slots = len(config.get(CONF_CONNECTIONS, ())) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) - if not slots: - return - - await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) + await _connections_to_code(var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 88d8cc1885..0a16567549 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -132,13 +132,6 @@ void BluetoothProxy::log_advertisement_flush_() { } void BluetoothProxy::dump_config() { -#ifdef USE_ESP32 - ESP_LOGCONFIG(TAG, - "Bluetooth Proxy:\n" - " Active: %s\n" - " Connections: %d", - YESNO(this->active_), this->connection_count_); -#else // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. @@ -162,32 +155,8 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif -#endif } -#ifdef USE_ESP32 - -void BluetoothProxy::loop() { - // Run advertisement flush / connection cleanup every 100ms - uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_advertisement_flush_time_ < 100) - return; - this->last_advertisement_flush_time_ = now; - - if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { - this->flush_pending_advertisements_(); - return; - } - 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(); - } - } -} - -#endif // USE_ESP32 - #ifdef BLUETOOTH_CONNECTION_HAS_GATT // maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. @@ -200,11 +169,8 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c ESP_LOGE(TAG, "Connection registry full, dropping registration"); return; } -#ifndef USE_ESP32 - // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub - // class has no Component lifecycle, so the index is assigned here. + // The hub wrapper has no Component lifecycle, so the index is assigned here. connection->connection_index_ = this->connection_count_; -#endif this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; #endif @@ -274,16 +240,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == ClientState::CONNECTING) { - if (connection->disconnect_pending()) { - ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str()); - connection->cancel_pending_disconnect(); - return; - } - this->log_connection_request_ignored_(connection, connection->state()); + } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { + ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", + connection->get_connection_index(), connection->address_str()); return; } else if (connection->state() != ClientState::INIT) { + // Covers CONNECTING too: a repeat request during a connect attempt is + // ignored the same way. this->log_connection_request_ignored_(connection, connection->state()); return; } @@ -315,7 +278,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { - // Both connection classes expose the same pairing surface; success is + // The connection wrapper exposes the pairing surface; success is // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { @@ -486,11 +449,33 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #else // !USE_ESP32 +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->hub_->scan_active() != active) { + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + if (!this->hub_->request_scan_mode(active)) { + // Passive-only controller asked for active scanning; the state report + // below carries the real, unchanged mode so the subscriber does not + // assume the change happened. + ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); + } + } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK + if (this->api_connection_ != nullptr) { + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. + this->send_polled_scanner_state_(); + } +#endif +} + +#endif // USE_ESP32 + void BluetoothProxy::loop() { #ifdef BLUETOOTH_CONNECTION_HAS_GATT - // Stream pending service-discovery batches every iteration (esp32 parity: - // its connections stream from their own per-iteration Component loop). - // send_service_for_discovery_() handles a vanished API connection itself. + // Stream pending service-discovery batches every iteration; the streamer + // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { this->connections_[i]->process_pending_services(); } @@ -502,10 +487,19 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; + if (this->connections_free_pending_ && this->api_connection_ != nullptr) { + // Resend a dropped slot-state update, paced by the 100 ms gate so the + // retry does not hammer the congestion it exists to survive; the + // advertisement-only arm answers DISCONNECT requests with this message + // too, so the drain compiles on every proxy build. + this->connections_free_pending_ = false; + this->send_connections_free(this->api_connection_); + } + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting backend is a no-op). + // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; if (connection->get_address() != 0) { @@ -550,12 +544,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: - this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { + // Address-scoped maintenance needs no connection slot: real on esp32 + // (Bluedroid bond table), the stub elsewhere keeps the old error reply. + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: - this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); + } + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; + } } } @@ -595,29 +595,6 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT -void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->hub_->scan_active() != active) { - ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - if (!this->hub_->request_scan_mode(active)) { - // Passive-only controller asked for active scanning; the state report - // below carries the real, unchanged mode so the subscriber does not - // assume the change happened. - ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); - } - } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK - if (this->api_connection_ != nullptr) { - // Reports the mode change; the sender also refreshes last_scan_running_, so - // a failed restart (scan_running_ dropped by the tracker) is not reported - // again by loop() on the next tick. A push hub reports the restart's - // transitions (mode rides along) instead. - this->send_polled_scanner_state_(); - } -#endif -} - -#endif // USE_ESP32 - void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { // A previous subscriber still holds the slot. This is almost always a stale @@ -631,6 +608,8 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), this->api_connection_->get_peername_to(old_peername)); } + // A stale retry latch belongs to the previous subscriber's session. + this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). @@ -646,6 +625,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; + this->connections_free_pending_ = false; } void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { @@ -656,6 +636,8 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui call.connected = connected; call.mtu = mtu; call.error = error; + // Fire and forget: a drop is covered by the client's own timeouts and the + // retried connections-free state. this->api_connection_->send_message(call); } void BluetoothProxy::send_connections_free() { @@ -665,7 +647,13 @@ void BluetoothProxy::send_connections_free() { } void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { - api_connection->send_message(this->connections_free_response_); + // Latch only for the current subscriber: loop() resends to api_connection_. + if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) { + // V like the api layer's own buffer-full log: a D would ride the same + // full connection. + ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full"); + this->connections_free_pending_ = true; + } } 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 d7150617d3..26f99fcca2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -5,8 +5,6 @@ #ifdef USE_BLUETOOTH_PROXY #include -#include -#include #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" @@ -17,11 +15,7 @@ #include "esphome/components/ble_device_base/ble_hub_impl.h" -#ifdef USE_ESP32 -#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" -#endif namespace esphome::bluetooth_proxy { @@ -29,7 +23,6 @@ namespace esphome::bluetooth_proxy { // re-exported here so the proxy code reads unqualified. using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; -using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; @@ -261,6 +254,10 @@ class BluetoothProxy final : public Component { // Group 4: 1-byte types grouped together bool active_; + // A dropped send (full TCP buffer) would leave the API client with a stale + // slot state forever; the cached response is current by construction, so + // retrying it from loop() is an idempotent resync. + bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML #ifndef USE_BLE_SCANNER_STATE_CALLBACK diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c0a3b99968..c82c2b3dbe 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Collection from esphome.const import ( CONF_LEVEL, @@ -98,6 +98,19 @@ def merge_config(old, new): return new +def frameworks_for_platforms(platforms: Collection[str]) -> set[PlatformFramework]: + """All PlatformFramework members whose platform is in `platforms`. + + For FILTER_SOURCE_FILES maps that must stay in sync with a platform + registry: deriving the framework set here means a platform added to the + registry cannot validate and then fail at link on a filtered-out file. + """ + known = {pf.value[0].value for pf in PlatformFramework} + if unknown := set(platforms) - known: + raise ValueError(f"unknown platform(s): {sorted(unknown)}") + return {pf for pf in PlatformFramework if pf.value[0].value in platforms} + + def filter_source_files_from_platform( files_map: dict[str, set[PlatformFramework]], ) -> Callable[[], list[str]]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7be217383e..bfb019d7ae 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -306,6 +306,8 @@ #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_TRACKER +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index e784c9871e..86ee53fe8a 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -109,6 +109,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + # One neutral GATT backend slot per connection (the hub-model flip). + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" def test_counts_reset_between_compiles( diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 32e5daf4bb..765b2e48d4 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -15,6 +15,13 @@ import voluptuous as vol from esphome import config_validation as cv from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema + +def _esp32_schema_keys() -> dict[str, object]: + # The builder names its platform explicitly, so no CORE state is needed + # (this also mirrors how the language-schema dumper calls it). + return _keys(_schema_of(_esp32_config_schema())) + + # esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA # plumbing (derived, so a future core key does not fail this component's test), # generated IDs (not user-walkable options), and connections (must validate @@ -38,7 +45,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]: def test_outer_scalar_keys_exist_in_esp32_schema() -> None: outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() missing = set(outer) - set(esp32) assert not missing, ( f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " @@ -51,7 +58,7 @@ def test_esp32_scalars_all_walkable() -> None: """Every non-generated esp32 scalar option must appear in the outer schema (connections is deliberately excluded — it must validate exactly once).""" outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() scalar = { name for name, key in esp32.items() diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 55e6fe2ca7..a47dfd53fa 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -9,11 +9,13 @@ import pytest from esphome import config_validation as cv from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy +from esphome.config_helpers import frameworks_for_platforms from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, PlatformFramework, @@ -177,28 +179,49 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) +def test_hub_source_filter_covers_every_hub_platform() -> None: + # bluetooth_connection cannot import this module to derive the hub.cpp + # framework set, so pin it here: a platform admitted to the proxy but + # missing from the filter would validate, then fail at link. + expected = frameworks_for_platforms( + [*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32] + ) + hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[ + "bluetooth_connection_hub.cpp" + ] + assert expected == hub_frameworks + + def test_bluetooth_connection_auto_load_covers_its_includes() -> None: - # The esp32 connection header includes esp32_ble_client; the auto load - # must satisfy that closure itself (regression: it once relied on the - # consumer's auto loads). + # The backend registers with its platform BLE stack (and the Bluedroid + # header includes the tracker's), so that closure lives here and + # consumers stay platform-blind; the platform-less arm is the union for + # manifest-resolving tooling. _set_platform("esp32") - assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"] _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"] + _set_platform("ln882x") assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] - # No target platform (tooling resolving the manifest): the union, so - # dependency closures stay complete for build_codeowners and friends. _set_platform(None) - assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] + assert bluetooth_connection.AUTO_LOAD() == [ + "ble_device_base", + "esp32_ble_tracker", + "rp2040_ble", + ] def test_every_registered_hub_platform_has_a_schema_arm() -> None: - # A platform added to HUB_MAX_CONNECTIONS without a schema builder, - # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for - # it is validated (or not even then); pin all three couplings here. + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) - assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared codegen + # helpers dispatch on. + assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) # The outer walkable schema's bound must stay the loosest platform cap. assert ( max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) @@ -220,9 +243,14 @@ def test_defines_h_mirrors_the_rp2_slot_cap() -> None: assert int(match.group(1)) == cap, ( f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" ) - # The static-analysis client count scales with the same cap. - match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines) - assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h" + # The static-analysis client count scales with the same cap. Scoped to + # the USE_RP2 block: the esp32 arm carries its own count. + rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines) + assert rp2_block is not None, "no USE_RP2 platform block in defines.h" + match = re.search( + r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1) + ) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block" assert int(match.group(1)) == cap, ( - f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" ) diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index 25b6cbf002..89eb56642e 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -2,7 +2,7 @@ // configured; this TU pins it on the host so the header cannot rot unseen. // The contract is a concept (BLEGattConnection is a per-platform alias), so // the minimal backend here proves the concept stays satisfiable and routes -// events through the duck-typed sink the way a real backend does. +// events through the GattClientListener interface the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -11,37 +11,37 @@ namespace esphome::ble_device_base::testing { -struct RecordingSink { - void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } - void on_service_discovery_done(int error) { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} - void on_write_result(uint16_t handle, int error) {} - void on_notify_state(uint16_t handle, bool enabled, int error) {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} - void on_pairing_result(int status) {} +// Overrides only what it records; the interface's defaults cover the rest. +class RecordingListener : public GattClientListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; } + bool connected_{false}; int discovery_error_{0}; + uint16_t write_handle_{0}; }; -static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); - class MinimalConnection { public: - void set_listener(RecordingSink *listener) { this->listener_ = listener; } + void set_listener(GattClientListener *listener) { this->listener_ = listener; } int connect(uint64_t address, uint8_t addr_type) { - if (this->listener_ != nullptr) - this->listener_->on_connection_state(true, 517, 0); + this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() { return 0; } + bool cancel_gatt_disconnect() { return false; } + int gatt_disconnect() { return 0; } int discover_services() { - if (this->listener_ != nullptr) - this->listener_->on_service_discovery_done(0); + this->listener_->on_service_discovery_done(0); return 0; } int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + this->listener_->on_write_result(handle, 0); + return 0; + } int read_descriptor(uint16_t handle) { return 0; } int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } int notify_characteristic(uint16_t handle, bool enable) { return 0; } @@ -51,23 +51,26 @@ class MinimalConnection { } GattServiceTable get_service_table() { return {}; } void release_services() {} + void set_connection_type(ConnectionType ct) {} protected: - RecordingSink *listener_{nullptr}; + GattClientListener *listener_{nullptr}; }; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingSink listener; + RecordingListener listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); EXPECT_EQ(connection.discover_services(), 0); EXPECT_EQ(listener.discovery_error_, 0); EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0); + EXPECT_EQ(listener.write_handle_, 7); // A default table is empty and safe to walk. GattServiceTable table = connection.get_service_table(); diff --git a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml new file mode 100644 index 0000000000..b3445f16c8 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml @@ -0,0 +1,12 @@ +# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is +# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the +# address-scoped maintenance path that a connections build never exercises. +# Under batch grouping the active default build is what runs; the standalone +# compile of this fixture is what exercises the passive gating. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 1c850e3759..88913c0f23 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -3,7 +3,13 @@ from collections.abc import Callable from unittest.mock import patch -from esphome.config_helpers import filter_source_files_from_platform, get_logger_level +import pytest + +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, + get_logger_level, +) from esphome.const import ( CONF_LEVEL, CONF_LOGGER, @@ -133,3 +139,12 @@ def test_get_logger_level() -> None: mock_config = {CONF_LOGGER: {}} with patch("esphome.config_helpers.CORE.config", mock_config): assert get_logger_level() == "DEBUG" + + +def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: + assert frameworks_for_platforms(["esp32"]) == { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + } + with pytest.raises(ValueError, match="unknown platform"): + frameworks_for_platforms(["esp32", "not_a_platform"]) From e90b4abe9c07549f745cfe4318868524b292b34b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 11:20:24 -0500 Subject: [PATCH 054/597] [core] Enforce the preferences contracts with concepts (#18191) --- esphome/core/defines.h | 12 ++- esphome/core/preference_backend.h | 45 +++++++++ esphome/core/preferences.h | 10 ++ tests/component_tests/preferences/__init__.py | 0 .../preferences/config/bk72xx.yaml | 5 + .../preferences/config/esp32.yaml | 5 + .../preferences/config/esp8266.yaml | 5 + .../preferences/config/host.yaml | 4 + .../preferences/config/nrf52.yaml | 6 ++ .../preferences/config/rp2.yaml | 5 + .../preferences/test_key_lookup_gate.py | 39 ++++++++ .../core/test_preference_contract.cpp | 91 +++++++++++++++++++ 12 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/preferences/__init__.py create mode 100644 tests/component_tests/preferences/config/bk72xx.yaml create mode 100644 tests/component_tests/preferences/config/esp32.yaml create mode 100644 tests/component_tests/preferences/config/esp8266.yaml create mode 100644 tests/component_tests/preferences/config/host.yaml create mode 100644 tests/component_tests/preferences/config/nrf52.yaml create mode 100644 tests/component_tests/preferences/config/rp2.yaml create mode 100644 tests/component_tests/preferences/test_key_lookup_gate.py create mode 100644 tests/components/core/test_preference_contract.cpp diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bfb019d7ae..49b9583be3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,9 +157,17 @@ #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP -// Only defined by key-lookup preference backends (esp32, libretiny, host, zephyr); -// slot-based platforms (esp8266, rp2040) never set it in generated builds +// Only defined by key-lookup preference backends; the slot-based platforms +// (esp8266, rp2040) never set it in generated builds, and their preferences +// managers do not provide load_from_key(), so the PreferencesKeyLookupContract +// assert would fail their clang-tidy environments. Written as a deny-list so +// the no-platform analysis configuration (whose Preferences stub provides +// load_from_key()) keeps covering the key-lookup code paths, and so a future +// slot-based platform fails the assert loudly instead of silently losing +// analysis coverage. +#if !defined(USE_ESP8266) && !defined(USE_RP2) #define USE_PREFERENCE_KEY_LOOKUP +#endif #define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index b9bb9a0252..0622376fca 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/core/defines.h" @@ -30,6 +31,15 @@ namespace esphome { +// The PreferenceBackend method surface, asserted on the alias each platform +// header binds. save() persists len bytes; load() fills dest only when the +// stored data exists and matches len. Both report success as their return. +template +concept PreferenceBackendContract = requires(T backend, const uint8_t *src, uint8_t *dest, size_t len) { + { backend.save(src, len) } -> std::same_as; + { backend.load(dest, len) } -> std::same_as; +}; + #if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. @@ -40,6 +50,8 @@ struct PreferenceBackend { #endif using ESPPreferenceBackend = PreferenceBackend; +static_assert(PreferenceBackendContract, + "The platform's preference backend is missing part of the PreferenceBackend surface"); class ESPPreferenceObject { public: @@ -68,6 +80,39 @@ class ESPPreferenceObject { PreferenceBackend *backend_{nullptr}; }; +// The preferences manager method surface, asserted in esphome/core/preferences.h +// on the ESPPreferences alias each platform's preferences.h binds through +// DECLARE_PREFERENCE_ALIASES. Semantics beyond the signatures: +// - make_preference: the two-argument form applies the platform's historic +// default storage; in_flash=false may fall back to flash where the platform +// has no faster storage. +// - sync: commit pending writes to flash, true on success. +// - reset: forget unsaved changes and re-initialize the permanent storage +// (usually followed by a restart), true on success. +// The template forms are what component call sites use; PreferencesMixin +// supplies them, but the derived class's non-template overloads hide them +// unless it also declares `using PreferencesMixin::make_preference;`, so +// the concept pins those too. +template +concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool in_flash) { + { prefs.make_preference(len, type, in_flash) } -> std::same_as; + { prefs.make_preference(len, type) } -> std::same_as; + { prefs.template make_preference(type, in_flash) } -> std::same_as; + { prefs.template make_preference(type) } -> std::same_as; + { prefs.sync() } -> std::same_as; + { prefs.reset() } -> std::same_as; +}; + +// Key-lookup platforms additionally provide load_from_key(), a one-shot read +// of a stored preference by key that migrate_preference() relies on; see the +// key-lookup note at the top of this file. Not part of PreferencesContract, +// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP +// is set. +template +concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { + { prefs.load_from_key(type, data, len) } -> std::same_as; +}; + /// CRTP mixin providing type-safe template make_preference() helpers. /// Platform preferences classes inherit this to avoid duplicating these templates. template class PreferencesMixin { diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index d24d51164a..cfeddebda7 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -45,8 +45,18 @@ extern ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-no } // namespace esphome #endif +namespace esphome { +static_assert(PreferencesContract, + "The platform's preferences manager is missing part of the ESPPreferences surface " + "(esphome/core/preference_backend.h)"); +} // namespace esphome + #ifdef USE_PREFERENCE_KEY_LOOKUP namespace esphome { +static_assert(PreferencesKeyLookupContract, + "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " + "load_from_key() (esphome/core/preference_backend.h)"); + /// Copy preference data stored under old_key into new_pref (created for new_key) if the keys /// differ and new_pref has no data yet. scratch must hold at least size bytes. /// Returns true when scratch holds the entity's current data (loaded or just migrated). diff --git a/tests/component_tests/preferences/__init__.py b/tests/component_tests/preferences/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/preferences/config/bk72xx.yaml b/tests/component_tests/preferences/config/bk72xx.yaml new file mode 100644 index 0000000000..9ea4154bca --- /dev/null +++ b/tests/component_tests/preferences/config/bk72xx.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +bk72xx: + board: generic-bk7252 diff --git a/tests/component_tests/preferences/config/esp32.yaml b/tests/component_tests/preferences/config/esp32.yaml new file mode 100644 index 0000000000..586979d7b6 --- /dev/null +++ b/tests/component_tests/preferences/config/esp32.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +esp32: + board: esp32dev diff --git a/tests/component_tests/preferences/config/esp8266.yaml b/tests/component_tests/preferences/config/esp8266.yaml new file mode 100644 index 0000000000..b8a1035159 --- /dev/null +++ b/tests/component_tests/preferences/config/esp8266.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +esp8266: + board: esp01_1m diff --git a/tests/component_tests/preferences/config/host.yaml b/tests/component_tests/preferences/config/host.yaml new file mode 100644 index 0000000000..047f8693a8 --- /dev/null +++ b/tests/component_tests/preferences/config/host.yaml @@ -0,0 +1,4 @@ +esphome: + name: preftest + +host: diff --git a/tests/component_tests/preferences/config/nrf52.yaml b/tests/component_tests/preferences/config/nrf52.yaml new file mode 100644 index 0000000000..00892addb5 --- /dev/null +++ b/tests/component_tests/preferences/config/nrf52.yaml @@ -0,0 +1,6 @@ +esphome: + name: preftest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 diff --git a/tests/component_tests/preferences/config/rp2.yaml b/tests/component_tests/preferences/config/rp2.yaml new file mode 100644 index 0000000000..d57b96a54e --- /dev/null +++ b/tests/component_tests/preferences/config/rp2.yaml @@ -0,0 +1,5 @@ +esphome: + name: preftest + +rp2: + board: rpipicow diff --git a/tests/component_tests/preferences/test_key_lookup_gate.py b/tests/component_tests/preferences/test_key_lookup_gate.py new file mode 100644 index 0000000000..7726113aab --- /dev/null +++ b/tests/component_tests/preferences/test_key_lookup_gate.py @@ -0,0 +1,39 @@ +"""Every preferences platform either emits USE_PREFERENCE_KEY_LOOKUP from +codegen (key-lookup backends) or must not (slot-based backends, whose managers +have no load_from_key()). Run each platform's real codegen and assert the +emission, mirroring the split the deny-list in esphome/core/defines.h assumes +for static analysis. + +The fixtures cover every distinct preferences backend today: ln882x and +rtl87xx route through libretiny (bk72xx stands in for the family), rp2040 is +an alias of rp2, and nrf52 exercises zephyr. A seventh backend needs a new +fixture here.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + + +@pytest.mark.parametrize( + ("fixture", "emits"), + [ + ("esp32.yaml", True), + ("bk72xx.yaml", True), # libretiny + ("host.yaml", True), + ("nrf52.yaml", True), # zephyr + ("esp8266.yaml", False), + ("rp2.yaml", False), + ], +) +def test_key_lookup_define_matches_the_platform_backend( + fixture: str, + emits: bool, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + defines = {define.name for define in CORE.defines} + assert ("USE_PREFERENCE_KEY_LOOKUP" in defines) is emits diff --git a/tests/components/core/test_preference_contract.cpp b/tests/components/core/test_preference_contract.cpp new file mode 100644 index 0000000000..f0833a5929 --- /dev/null +++ b/tests/components/core/test_preference_contract.cpp @@ -0,0 +1,91 @@ +// Pins the preferences contract concepts so the surface they enforce cannot +// drift unnoticed: a minimal conforming type must satisfy each concept, and a +// type missing a method or returning the wrong type must not. + +#include + +#include "esphome/core/preference_backend.h" + +namespace esphome::core::testing { + +struct MinimalBackend { + bool save(const uint8_t *, size_t) { return true; } + bool load(uint8_t *, size_t) { return true; } +}; +static_assert(PreferenceBackendContract); + +struct BackendMissingLoad { + bool save(const uint8_t *, size_t) { return true; } +}; +static_assert(!PreferenceBackendContract); + +struct BackendWrongReturn { + void save(const uint8_t *, size_t) {} + bool load(uint8_t *, size_t) { return true; } +}; +static_assert(!PreferenceBackendContract); + +struct MinimalPreferences : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(PreferencesContract); + +struct PreferencesMissingTwoArgForm : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +struct PreferencesMissingReset : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } +}; +static_assert(!PreferencesContract); + +struct PreferencesWrongSyncReturn : public PreferencesMixin { + using PreferencesMixin::make_preference; + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + void sync() {} + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +// Forgot `using PreferencesMixin::make_preference;`, so the derived +// overloads hide the template forms (see the PreferencesContract note in +// preference_backend.h); the concept must reject the class. +struct PreferencesForgotUsingDeclaration : public PreferencesMixin { + ESPPreferenceObject make_preference(size_t, uint32_t, bool) { return {}; } + ESPPreferenceObject make_preference(size_t, uint32_t) { return {}; } + bool sync() { return true; } + bool reset() { return true; } +}; +static_assert(!PreferencesContract); + +struct MinimalKeyLookup { + bool load_from_key(uint32_t, uint8_t *, size_t) { return true; } +}; +static_assert(PreferencesKeyLookupContract); + +struct KeyLookupMissingMethod {}; +static_assert(!PreferencesKeyLookupContract); + +TEST(PreferenceContract, NullBackendRefusesBothOperations) { + // ESPPreferenceObject forwards to whichever backend the platform binds; a + // default-constructed object has no backend and must refuse both operations + // instead of crashing. + ESPPreferenceObject without_backend; + uint32_t value = 42; + EXPECT_FALSE(without_backend.save(&value)); + EXPECT_FALSE(without_backend.load(&value)); +} + +} // namespace esphome::core::testing From 2798ef4de29e0b7d8d05994f23f06acde1631926 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 11:20:38 -0500 Subject: [PATCH 055/597] [ota] Enforce the backend contract with a concept (#18192) --- esphome/components/ota/ota_backend.h | 21 ++++++++ esphome/components/ota/ota_backend_factory.h | 13 ++++- .../components/ota/test_backend_contract.cpp | 49 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/components/ota/test_backend_contract.cpp diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 01be46a518..aa93df60a5 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,6 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include +#include #include #ifdef USE_OTA_STATE_LISTENER @@ -78,6 +80,25 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +// The OTA backend method surface. Exactly one backend exists per build, +// selected in ota_backend_factory.h where this concept is asserted on +// make_ota_backend()'s return type. Semantics beyond the signatures: +// - begin: prepare for an image of the given size; ota_type defaults to an +// app update, so both call forms must be accepted. +// - set_update_md5: expected digest of the incoming image, hex string. +// - write: consume the next chunk; end: finalize and mark bootable. +// - abort: safe to call in any state, including after end(). +template +concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) { + { backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as; + { backend.begin(image_size) } -> std::same_as; + backend.set_update_md5(md5); + { backend.write(data, len) } -> std::same_as; + { backend.end() } -> std::same_as; + backend.abort(); + { backend.supports_compression() } -> std::same_as; +}; + /** Listener interface for OTA state changes. * * Components can implement this interface to receive OTA state updates diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index c543983d8d..82d001ed9e 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -17,11 +17,22 @@ #else // Stub for static analysis when no platform is defined namespace esphome::ota { -struct StubOTABackend {}; +struct StubOTABackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { + return OTA_RESPONSE_ERROR_UNKNOWN; + } + void set_update_md5(const char *md5) {} + OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; } + OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; } + void abort() {} + bool supports_compression() { return false; } +}; std::unique_ptr make_ota_backend(); } // namespace esphome::ota #endif namespace esphome::ota { using OTABackendPtr = decltype(make_ota_backend()); +static_assert(OTABackendContract, + "The platform's OTA backend is missing part of the backend surface (ota_backend.h)"); } // namespace esphome::ota diff --git a/tests/components/ota/test_backend_contract.cpp b/tests/components/ota/test_backend_contract.cpp new file mode 100644 index 0000000000..1b4fbbc32d --- /dev/null +++ b/tests/components/ota/test_backend_contract.cpp @@ -0,0 +1,49 @@ +// Pins the OTA backend contract concept so the surface it enforces cannot +// drift unnoticed: the build's real backend and a minimal conforming type +// must satisfy it, and a type missing a method or returning the wrong type +// must not. + +#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/ota/ota_backend_host.h" + +namespace esphome::ota::testing { + +struct MinimalBackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_OK; } + void set_update_md5(const char *md5) {} + OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; } + OTAResponseTypes end() { return OTA_RESPONSE_OK; } + void abort() {} + bool supports_compression() { return false; } +}; +static_assert(OTABackendContract); + +// Each negative case derives from MinimalBackend and breaks exactly one +// requirement; the declaration in the derived struct hides the conforming +// one from the base. + +// begin() without the default ota_type argument breaks consumers that only +// pass the image size. +struct BackendWithoutDefaultOTAType : MinimalBackend { + OTAResponseTypes begin(size_t image_size, OTAType ota_type) { return OTA_RESPONSE_OK; } +}; +static_assert(!OTABackendContract); + +struct BackendMissingAbort : MinimalBackend { + void abort() = delete; +}; +static_assert(!OTABackendContract); + +struct BackendWrongWriteReturn : MinimalBackend { + bool write(uint8_t *data, size_t len) { return true; } +}; +static_assert(!OTABackendContract); + +// Pin the build's real backend, not just local mocks: the unit test harness +// builds for the host platform, so this is the same check the factory's +// static_assert performs in a firmware compile. +#ifdef USE_HOST +static_assert(OTABackendContract); +#endif + +} // namespace esphome::ota::testing From 56ec21d950494463f0d079c5b2eb4c96e058869e Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Mon, 10 Aug 2026 18:31:31 +0200 Subject: [PATCH 056/597] [ufm01] Improve startup with reset retry and passive polling fallback (#17567) Co-authored-by: Cursor Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ufm01/ufm01.cpp | 306 ++++++++++++++++-- esphome/components/ufm01/ufm01.h | 56 +++- tests/components/ufm01/common.h | 156 +++++++++ tests/components/ufm01/ufm01_frame_test.cpp | 83 +++++ tests/components/ufm01/ufm01_startup_test.cpp | 43 +++ 5 files changed, 612 insertions(+), 32 deletions(-) create mode 100644 tests/components/ufm01/common.h create mode 100644 tests/components/ufm01/ufm01_frame_test.cpp create mode 100644 tests/components/ufm01/ufm01_startup_test.cpp diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 1380c34284..2859ee4aaa 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -4,13 +4,23 @@ #include "esphome/core/log.h" #include +#include +#include namespace esphome::ufm01 { static const char *const TAG = "ufm01"; static constexpr uint8_t COMMAND_ACK = 0xE5; -static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t POST_RESET_DELAY_MS = 2000; +static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; +static constexpr uint32_t STARTUP_RETRY_MS = 3000; +static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; +static constexpr uint32_t ACTIVE_STALE_MS = 5000; +static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; @@ -18,12 +28,14 @@ static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; // Active-mode frame layout (datasheet Table 7) static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; @@ -55,7 +67,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp return false; } -static bool validate_data(uint8_t data[FRAME_SIZE]) { +static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { uint8_t sum = 0; for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) sum += data[i]; @@ -68,13 +80,43 @@ static bool validate_data(uint8_t data[FRAME_SIZE]) { check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); } -static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { +static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < 21; ++i) + sum += data[i]; + return data[21] == (sum & 0xFF); +} + +static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { + std::memset(active, 0, FRAME_SIZE); + active[0] = FRAME_START_BYTE_1; + active[1] = FRAME_START_BYTE_2; + active[7] = 0x01; + active[8] = passive[2]; + for (size_t i = 0; i < 6; ++i) + active[9 + i] = passive[3 + i]; + active[15] = passive[9]; + for (size_t i = 0; i < 5; ++i) + active[16 + i] = passive[10 + i]; + active[21] = FRAME_FLAG_RESERVED_SECTION; + active[24] = passive[15]; + for (size_t i = 0; i < 3; ++i) + active[25 + i] = passive[16 + i]; + active[28] = passive[19]; + active[29] = passive[20]; + active[30] = passive[21]; + active[31] = FRAME_STOP_BYTE; +} + +static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); } -static float read_flow(uint8_t data[FRAME_SIZE]) { +static float read_flow(const uint8_t data[FRAME_SIZE]) { return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + to_float(data[16]) * 0.01f) * @@ -86,7 +128,7 @@ static void log_hex(const uint8_t *data, size_t len) { ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } -static float read_temperature(uint8_t data[FRAME_SIZE]) { +static float read_temperature(const uint8_t data[FRAME_SIZE]) { // happens sometimes before getting a real reading if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { return NAN; @@ -106,19 +148,39 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; } -bool UFM01Component::send_command_(const std::array &command) { +void UFM01Component::flush_rx_() { + while (this->available()) { + uint8_t byte; + this->read_byte(&byte); + } + this->read_index_ = 0; +} + +void UFM01Component::send_command_no_wait_(const std::array &command) { + this->flush_rx_(); this->write_array(command); this->flush(); +} + +// Drains whatever is currently in the RX buffer, looking for a command ACK. +bool UFM01Component::consume_ack_() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + return false; + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + return false; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->send_command_no_wait_(command); const uint32_t start = millis(); while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->available()) { - uint8_t byte; - if (this->read_byte(&byte)) { - if (byte == COMMAND_ACK) - return true; - ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); - } - } + if (this->consume_ack_()) + return true; delay(1); } return false; @@ -130,14 +192,12 @@ bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEA bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } -float UFM01Component::get_setup_priority() const { return setup_priority::IO; } +float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); - if (!this->set_active_mode_()) { - ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); - this->mark_failed(); - } + this->startup_wait_ms_ = STARTUP_DELAY_MS; + this->set_startup_phase_(StartupPhase::WAIT); } void UFM01Component::dump_config() { @@ -154,12 +214,9 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); - if (this->is_failed()) { - ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); - } } -void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -189,10 +246,14 @@ void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif + this->last_valid_frame_ms_ = millis(); + this->status_clear_warning(); + this->status_clear_error(); } -void UFM01Component::loop() { - // Drain the UART buffer each loop, reading one byte at a time into the frame +bool UFM01Component::process_active_stream_() { + bool got_valid_frame = false; + while (this->available()) { if (!this->read_byte(&this->data_[this->read_index_])) { ESP_LOGW(TAG, "unable to read byte"); @@ -201,23 +262,22 @@ void UFM01Component::loop() { } if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { - ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + ESP_LOGD(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); this->read_index_ = 0; continue; } if (++this->read_index_ < static_cast(FRAME_SIZE)) continue; - // Full frame received - if (validate_data(this->data_)) { - this->on_data_(this->data_); + if (validate_active_frame(this->data_)) { + this->on_active_frame_(this->data_); this->read_index_ = 0; + got_valid_frame = true; continue; } - // Invalid frame: try to resync on the next start marker within the buffer log_hex(this->data_, sizeof(this->data_)); - ESP_LOGE(TAG, "unable to read data"); + ESP_LOGW(TAG, "unable to read data"); for (int32_t i = 2; i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { @@ -229,6 +289,190 @@ void UFM01Component::loop() { if (this->read_index_ == static_cast(FRAME_SIZE)) this->read_index_ = 0; } + + return got_valid_frame; +} + +void UFM01Component::set_startup_phase_(StartupPhase phase) { + this->startup_phase_ = phase; + this->phase_start_ms_ = millis(); +} + +void UFM01Component::enter_active_stream_(const char *reason) { + ESP_LOGI(TAG, "UFM-01 active stream %s", reason); + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->passive_read_pending_ = false; +} + +void UFM01Component::start_passive_read_() { + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + this->passive_index_ = 0; + this->passive_start_ms_ = millis(); +} + +// Accumulates the reply to a passive read request across loop iterations. +PassiveReadResult UFM01Component::continue_passive_read_() { + while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + + if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) + continue; + if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + // The mismatched byte may itself be the start of the real frame + this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; + continue; + } + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < PASSIVE_FRAME_SIZE) { + if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return PassiveReadResult::PENDING; + ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); + return PassiveReadResult::FAILURE; + } + + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return PassiveReadResult::FAILURE; + } + + uint8_t active_frame[FRAME_SIZE]; + passive_no_id_to_active_frame(this->passive_frame_, active_frame); + this->on_active_frame_(active_frame); + return PassiveReadResult::SUCCESS; +} + +void UFM01Component::loop_startup_() { + const uint32_t elapsed = millis() - this->phase_start_ms_; + + switch (this->startup_phase_) { + case StartupPhase::WAIT: + // Pick up an already-streaming device without resetting it + if (this->process_active_stream_()) { + this->enter_active_stream_("started"); + return; + } + if (elapsed < this->startup_wait_ms_) + return; + ESP_LOGD(TAG, "Running startup sequence"); + this->status_set_warning("initializing UFM-01"); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + return; + + case StartupPhase::RESET_WAIT_ACK: + if (this->consume_ack_()) { + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + return; + } + if (elapsed < COMMAND_ACK_TIMEOUT_MS) + return; + if (!this->reset_retried_) { + ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + } else { + ESP_LOGW(TAG, "Reset failed during startup"); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + } + return; + + case StartupPhase::RESET_RETRY_WAIT: + if (elapsed < RESET_RETRY_DELAY_MS) + return; + this->reset_retried_ = true; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + return; + + case StartupPhase::POST_RESET_WAIT: + if (elapsed < POST_RESET_DELAY_MS) + return; + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + return; + + case StartupPhase::ACTIVE_WAIT_FRAME: + // The command ACK (0xE5) is consumed by the frame parser as noise + if (this->process_active_stream_()) { + this->enter_active_stream_("started"); + return; + } + if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) + return; + this->start_passive_read_(); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + return; + + case StartupPhase::PASSIVE_WAIT_REPLY: + switch (this->continue_passive_read_()) { + case PassiveReadResult::PENDING: + return; + case PassiveReadResult::SUCCESS: + ESP_LOGI(TAG, "UFM-01 using passive polling"); + this->operating_mode_ = OperatingMode::PASSIVE_POLL; + this->passive_read_pending_ = false; + this->last_poll_ms_ = millis(); + return; + case PassiveReadResult::FAILURE: + ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); + this->startup_wait_ms_ = STARTUP_RETRY_MS; + this->set_startup_phase_(StartupPhase::WAIT); + return; + } + } +} + +void UFM01Component::loop_active_stream_() { + this->process_active_stream_(); + if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->operating_mode_ = OperatingMode::PASSIVE_POLL; + this->passive_read_pending_ = false; + this->last_poll_ms_ = 0; + this->status_set_warning("UFM-01 passive poll"); + } +} + +void UFM01Component::loop_passive_poll_() { + if (this->passive_read_pending_) { + const PassiveReadResult result = this->continue_passive_read_(); + if (result == PassiveReadResult::PENDING) + return; + this->passive_read_pending_ = false; + if (result == PassiveReadResult::FAILURE) + this->status_set_warning("UFM-01 passive poll failed"); + return; + } + + if (this->process_active_stream_()) { + this->enter_active_stream_("resumed"); + return; + } + + if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = millis(); + this->start_passive_read_(); + this->passive_read_pending_ = true; + } +} + +void UFM01Component::loop() { + switch (this->operating_mode_) { + case OperatingMode::STARTUP: + this->loop_startup_(); + return; + case OperatingMode::ACTIVE_STREAM: + this->loop_active_stream_(); + return; + case OperatingMode::PASSIVE_POLL: + this->loop_passive_poll_(); + return; + } } } // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index e759de9169..6c1da65167 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -11,12 +11,39 @@ #include "esphome/components/uart/uart.h" #include +#include // component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf namespace esphome::ufm01 { +namespace testing { +class TestableUFM01; +} // namespace testing + static constexpr size_t FRAME_SIZE = 32; +static constexpr size_t PASSIVE_FRAME_SIZE = 23; + +enum class OperatingMode : uint8_t { + STARTUP = 0, + ACTIVE_STREAM = 1, + PASSIVE_POLL = 2, +}; + +enum class StartupPhase : uint8_t { + WAIT = 0, + RESET_WAIT_ACK = 1, + RESET_RETRY_WAIT = 2, + POST_RESET_WAIT = 3, + ACTIVE_WAIT_FRAME = 4, + PASSIVE_WAIT_REPLY = 5, +}; + +enum class PassiveReadResult : uint8_t { + PENDING = 0, + SUCCESS = 1, + FAILURE = 2, +}; class UFM01Component : public uart::UARTDevice, public Component { #ifdef USE_SENSOR @@ -48,10 +75,37 @@ class UFM01Component : public uart::UARTDevice, public Component { private: bool send_command_(const std::array &command); + void send_command_no_wait_(const std::array &command); + bool consume_ack_(); + void flush_rx_(); + bool process_active_stream_(); + void on_active_frame_(uint8_t data[FRAME_SIZE]); + + void loop_startup_(); + void loop_active_stream_(); + void loop_passive_poll_(); + void set_startup_phase_(StartupPhase phase); + void enter_active_stream_(const char *reason); + void start_passive_read_(); + PassiveReadResult continue_passive_read_(); + + OperatingMode operating_mode_{OperatingMode::STARTUP}; + StartupPhase startup_phase_{StartupPhase::WAIT}; + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + bool reset_retried_{false}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + + bool passive_read_pending_{false}; + uint32_t passive_start_ms_{0}; + size_t passive_index_{0}; + uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; int32_t read_index_ = 0; uint8_t data_[FRAME_SIZE]; - void on_data_(uint8_t data[FRAME_SIZE]); + + friend class testing::TestableUFM01; }; } // namespace esphome::ufm01 diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h new file mode 100644 index 0000000000..1582358700 --- /dev/null +++ b/tests/components/ufm01/common.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/ufm01/ufm01.h" + +namespace esphome::ufm01::testing { + +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; +static constexpr uint8_t COMMAND_ACK = 0xE5; + +// UART mock with a byte queue for read-side simulation. +class QueuedMockUART : public uart::UARTComponent { + public: + std::deque rx_queue; + std::vector written_data; + + void enqueue(const std::vector &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + + void enqueue(std::initializer_list data) { + for (uint8_t byte : data) + this->rx_queue.push_back(byte); + } + + void clear_rx() { this->rx_queue.clear(); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx_queue.size() < len) + return false; + for (size_t i = 0; i < len; ++i) { + data[i] = this->rx_queue.front(); + this->rx_queue.pop_front(); + } + return true; + } + + bool peek_byte(uint8_t *data) override { + if (this->rx_queue.empty()) + return false; + *data = this->rx_queue.front(); + return true; + } + + size_t available() override { return this->rx_queue.size(); } + + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + + void write_array(const uint8_t *data, size_t len) override { this->written_data.assign(data, data + len); } + + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +class TestableUFM01 : public UFM01Component { + public: + void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } + + bool process_active_stream() { return this->process_active_stream_(); } + + PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + + bool consume_ack() { return this->consume_ack_(); } + + void start_passive_read() { this->start_passive_read_(); } + + void loop_startup() { this->loop_startup_(); } + + OperatingMode operating_mode() const { return this->operating_mode_; } + + StartupPhase startup_phase() const { return this->startup_phase_; } + + int32_t read_index() const { return this->read_index_; } + + size_t passive_index() const { return this->passive_index_; } + + uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + + void prepare_passive_read() { + this->passive_index_ = 0; + this->passive_start_ms_ = millis(); + } + + void init_wait_phase() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::WAIT; + this->startup_wait_ms_ = 60000; + this->phase_start_ms_ = millis(); + } + + void reset_state() { + this->read_index_ = 0; + this->last_valid_frame_ms_ = 0; + this->passive_index_ = 0; + this->passive_read_pending_ = false; + } +}; + +inline std::array make_active_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = FRAME_START_BYTE_2; + frame[15] = FRAME_FLAG_INSTANT_FLOW; + frame[21] = FRAME_FLAG_RESERVED_SECTION; + frame[24] = FRAME_FLAG_TEMP; + frame[31] = FRAME_STOP_BYTE; + uint8_t sum = 0; + for (size_t i = 0; i < 30; ++i) + sum += frame[i]; + frame[30] = sum; + return frame; +} + +inline std::array make_passive_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2; + frame[9] = FRAME_FLAG_INSTANT_FLOW; + frame[15] = FRAME_FLAG_TEMP; + frame[22] = FRAME_STOP_BYTE; + uint8_t sum = 0; + for (size_t i = 0; i < 21; ++i) + sum += frame[i]; + frame[21] = sum; + return frame; +} + +class UFM01Test : public ::testing::Test { + protected: + void SetUp() override { + this->mock_uart_.clear_rx(); + this->mock_uart_.written_data.clear(); + this->ufm01_.set_mock_uart(&this->mock_uart_); + this->ufm01_.reset_state(); + } + + QueuedMockUART mock_uart_; + TestableUFM01 ufm01_; +}; + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp new file mode 100644 index 0000000000..82d74b58a5 --- /dev/null +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -0,0 +1,83 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ValidActiveFrameAccepted) { + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); + EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { + this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); +} + +TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { + auto frame = make_active_frame(); + frame[30] ^= 0xFF; + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + + EXPECT_FALSE(this->ufm01_.process_active_stream()); + EXPECT_EQ(this->ufm01_.read_index(), 0); + EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); + EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, InvalidPassiveChecksumFails) { + auto frame = make_passive_frame(); + frame[21] ^= 0xFF; + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::FAILURE); + EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); +} + +TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue({0x00, 0x01, 0x02}); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); +} + +TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); +} + +TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { + auto frame = make_passive_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); + this->ufm01_.prepare_passive_read(); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PENDING); + EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); + + this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp new file mode 100644 index 0000000000..6b7feb0f1b --- /dev/null +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -0,0 +1,43 @@ +#include "common.h" + +#include "esphome/core/component.h" + +namespace esphome::ufm01::testing { + +TEST(UFM01SetupPriority, IsLate) { + TestableUFM01 ufm01; + EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); +} + +TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { + this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); + + EXPECT_TRUE(this->ufm01_.consume_ack()); + EXPECT_EQ(this->mock_uart_.available(), 1u); +} + +TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } + +TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::WAIT); +} + +TEST_F(UFM01Test, StartPassiveReadSendsCommand) { + this->ufm01_.start_passive_read(); + + ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); + EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); + EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); + EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); + EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); + EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); +} + +} // namespace esphome::ufm01::testing From bae7f1932329e9672297758551375e671b60a770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 11:32:02 -0500 Subject: [PATCH 057/597] [core] Extend '/' in names deprecation window to 2027.7.0 (#18236) --- esphome/config_validation.py | 4 ++-- tests/unit_tests/test_config_validation.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c04d43bbee..0eebf12e66 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2331,13 +2331,13 @@ def _validate_no_slash(value): the visually similar Unicode FRACTION SLASH (U+2044) character. """ if "/" in value: - # Remove before 2026.7.0 + # Remove before 2027.7.0 new_value = value.replace("/", FRACTION_SLASH) _LOGGER.warning( "'%s' contains '/' which is reserved as a URL path separator. " "Automatically replacing with '%s' (Unicode FRACTION SLASH). " "Please update your configuration. " - "This will become an error in ESPHome 2026.7.0.", + "This will become an error in ESPHome 2027.7.0.", value, new_value, ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4a4e37e5c4..7627ef9273 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -932,7 +932,7 @@ def test_string_no_slash__slash_replaced_with_warning( actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text - assert "will become an error in ESPHome 2026.7.0" in caplog.text + assert "will become an error in ESPHome 2027.7.0" in caplog.text def test_string_no_slash__long_string_allowed() -> None: From 2184ec292828be5a03e068fc809d785bc1ac43ad Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:24:16 -0700 Subject: [PATCH 058/597] [ethernet] Add CH390 SPI ethernet support (#18226) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/ethernet/__init__.py | 21 +++++-- .../components/ethernet/ethernet_component.h | 1 + .../ethernet/ethernet_component_esp32.cpp | 23 +++++++ esphome/core/defines.h | 1 + .../component_tests/ethernet/test_ethernet.py | 61 ++++++++++++++++++- tests/components/ethernet/common-ch390.yaml | 19 ++++++ .../ethernet/test-ch390.esp32-idf.yaml | 1 + 7 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/components/ethernet/common-ch390.yaml create mode 100644 tests/components/ethernet/test-ch390.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index d1d5e45c6b..8bdd536ffb 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -132,6 +132,7 @@ ETHERNET_TYPES = { "W6300": EthernetType.ETHERNET_TYPE_W6300, "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, + "CH390": EthernetType.ETHERNET_TYPE_CH390, } # PHY types that need compile-time defines for conditional compilation @@ -153,6 +154,7 @@ _PHY_TYPE_TO_DEFINE = { "W6300": "USE_ETHERNET_W6300", "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", + "CH390": "USE_ETHERNET_CH390", } @@ -176,13 +178,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), + "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), } # These types are always external IDF components (never built-in to ESP-IDF) -_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} +_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) -SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} # RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole # RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the # comment above is about ESP-IDF driver coverage, not the RP2 platform. @@ -480,6 +483,12 @@ SPI_SCHEMA = _spi_schema() # of spec for it and makes the driver's CS hold time helper compute no hold SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) +# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V, +# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not. +# CH390 datasheet v1.8, tables 9-4 and 9-5: +# https://www.wch-ic.com/downloads/CH390DS1_PDF.html +SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -494,6 +503,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, + "CH390": SPI_SCHEMA_CH390, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), @@ -629,8 +639,11 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 - # ENC28J60 was never built-in to IDF, so it has no Kconfig option - if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60": + # Types that are never built into IDF ship no Kconfig option at all + if ( + idf_version() < cv.Version(6, 0, 0) + and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS + ): add_idf_sdkconfig_option( f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True ) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 9f4398c621..dc084796e7 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -88,6 +88,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_W6300, ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, + ETHERNET_TYPE_CH390, }; struct ManualIP { diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 94f4c23479..7cf8cdf736 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -50,6 +50,12 @@ #include "esp_eth_enc28j60.h" #endif +// CH390 headers exist on all IDF versions (always an external component) +#ifdef USE_ETHERNET_CH390 +#include "esp_eth_mac_ch390.h" +#include "esp_eth_phy_ch390.h" +#endif + #ifdef USE_ETHERNET_SPI #include #include @@ -215,6 +221,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_ENC28J60) eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_CH390) + eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -236,6 +244,11 @@ void EthernetComponent::ethernet_lazy_init_() { // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; +#elif defined(USE_ETHERNET_CH390) + ch390_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ch390_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -360,6 +373,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_enc28j60(&phy_config); break; } +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: { + mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config); + this->phy_ = esp_eth_phy_new_ch390(&phy_config); + break; + } #endif #endif default: { @@ -519,6 +538,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_ENC28J60: eth_type = "ENC28J60"; break; +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: + eth_type = "CH390"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 49b9583be3..319018a36f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -397,6 +397,7 @@ #define USE_ETHERNET_W6100 #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 +#define USE_ETHERNET_CH390 #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index b3d37561c7..9308d0b099 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -1,13 +1,31 @@ -"""Tests for the ethernet final-validation coexistence gate.""" +"""Tests for the ethernet final-validation coexistence gate and schema bounds.""" import pytest from voluptuous import Invalid -from esphome.components.ethernet import _final_validate +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32S3, +) +from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate from esphome.components.network import _validate_priority_list -from esphome.const import CONF_PRIORITY +from esphome.const import CONF_PRIORITY, PlatformFramework +from esphome.core import CORE import esphome.final_validate as fv +from ..types import SetCoreConfigCallable + +_CH390_CONFIG = { + "type": "CH390", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + @pytest.fixture(autouse=True) def _reset_full_config(): @@ -35,3 +53,40 @@ def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None: ) with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"): _final_validate({}) + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"]) +def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """CH390 SCK is rated to 72MHz, so the schema must accept the whole range.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ch390-test" + config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_ch390_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ch390-test" + with pytest.raises(Invalid, match="value must be at most 72000000"): + CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/components/ethernet/common-ch390.yaml b/tests/components/ethernet/common-ch390.yaml new file mode 100644 index 0000000000..b27bc6ab4f --- /dev/null +++ b/tests/components/ethernet/common-ch390.yaml @@ -0,0 +1,19 @@ +ethernet: + type: CH390 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ch390.esp32-idf.yaml b/tests/components/ethernet/test-ch390.esp32-idf.yaml new file mode 100644 index 0000000000..50165d458f --- /dev/null +++ b/tests/components/ethernet/test-ch390.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ch390.yaml From b7c6245388e1fed932f1050061c023c55c83507c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 12:39:32 -0500 Subject: [PATCH 059/597] [wifi] Save fast connect settings and reset roaming bookkeeping after driver initiated roams (#18167) --- esphome/components/wifi/wifi_component.cpp | 25 ++++++++++++++++--- esphome/components/wifi/wifi_component.h | 8 +++++- .../wifi/wifi_component_esp_idf.cpp | 13 ++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 182e86daed..9e78e7c48e 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1676,7 +1676,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->clear_all_bssid_priorities_(); #ifdef USE_WIFI_FAST_CONNECT - this->save_fast_connect_settings_(); + this->save_fast_connect_settings_(this->wifi_bssid(), get_wifi_channel()); #endif this->release_scan_results_(); @@ -2301,9 +2301,7 @@ bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { return false; } -void WiFiComponent::save_fast_connect_settings_() { - bssid_t bssid = wifi_bssid(); - uint8_t channel = get_wifi_channel(); +void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel) { // selected_sta_index_ is always valid here (called only after successful connection) // Fallback to 0 is defensive programming for robustness int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0; @@ -2416,6 +2414,25 @@ void WiFiComponent::clear_roaming_state_() { this->roaming_state_ = RoamingState::IDLE; } +#ifdef USE_ESP32 +void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { + // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the state + // machine ever leaving STA_CONNECTED, so check_connecting_finished() never runs. + // Redo its post-connect bookkeeping here. roaming_state_ is deliberately left + // untouched so an in-flight roaming scan is not orphaned. The BSSID and + // channel both come from the connected event so the saved pair is consistent: + // the radio may be off-channel during a roaming scan, and a later queued + // event may have moved the driver on again by the time this one is processed. + this->roaming_last_check_ = App.get_loop_component_start_time(); + this->roaming_attempts_ = 0; + this->roaming_scan_end_ = 0; + this->clear_all_bssid_priorities_(); +#ifdef USE_WIFI_FAST_CONNECT + this->save_fast_connect_settings_(bssid, channel); +#endif +} +#endif + void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 43e44a135f..a851ea4015 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -781,13 +781,19 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); - void save_fast_connect_settings_(); + void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); void clear_roaming_state_(); +#ifdef USE_ESP32 + /// Redo post-connect bookkeeping after a driver-initiated roam (e.g. 802.11v BTM) + /// @param bssid The new AP's BSSID, taken from the connected event + /// @param channel The new AP's channel, taken from the connected event + void handle_driver_roam_(const bssid_t &bssid, uint8_t channel); +#endif /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). bool roaming_suppressed_() const { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d78cd21380..783c000f7b 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -825,6 +825,19 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { (const char *) it.ssid, bssid_buf, it.channel, get_auth_mode_str(it.authmode)); #endif s_sta_connected = true; + if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED) { + // Driver-initiated roam: the WIFI_REASON_ROAMING disconnect was ignored, + // so the state machine never left STA_CONNECTED. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO + char roam_bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(it.bssid, roam_bssid_s); + ESP_LOGI(TAG, "Roamed ssid='%.*s' bssid=" LOG_SECRET("%s") " channel=%u", it.ssid_len, (const char *) it.ssid, + roam_bssid_s, it.channel); +#endif + bssid_t roam_bssid; + std::copy(it.bssid, it.bssid + 6, roam_bssid.begin()); + this->handle_driver_roam_(roam_bssid, it.channel); + } #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Defer listener notification until state machine reaches STA_CONNECTED // This ensures wifi.connected condition returns true in listener automations From ad733272e56df06a53532f8eb4736fa91819ca13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 12:40:22 -0500 Subject: [PATCH 060/597] [bluetooth_proxy] Pair the advertisement flush time with hub_ to close alignment holes (#18234) --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 26f99fcca2..d3e3144831 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -242,13 +242,13 @@ class BluetoothProxy final : public Component { std::array connections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; + // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below + // start on an even word, closing two alignment holes. + uint32_t last_advertisement_flush_time_{0}; // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; - // 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_; From 8f1e4397920da4849771666ab71b474d9f1648b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 12:56:16 -0500 Subject: [PATCH 061/597] [bluetooth_proxy] Finish the connection scan before reserving a slot (#18239) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 0a16567549..af52a25ec0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -198,6 +198,10 @@ void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, con } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { + // Finish the scan before reserving: a free slot earlier in the array must + // not win over a later slot that already holds the address, or one device + // ends up on two slots with a second connection attempt racing the first. + BluetoothConnection *free_slot = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); @@ -205,18 +209,19 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese if (conn_addr == address) return connection; - if (reserve && conn_addr == 0) { - connection->send_service_ = INIT_SENDING_SERVICES; - connection->set_address(address); - // All connections must start at INIT - // We only set the state if we allocate the connection - // to avoid a race where multiple connection attempts - // are made. - connection->set_state(ClientState::INIT); - return connection; - } + if (free_slot == nullptr && conn_addr == 0) + free_slot = connection; } - return nullptr; + if (!reserve || free_slot == nullptr) + return nullptr; + free_slot->send_service_ = INIT_SENDING_SERVICES; + free_slot->set_address(address); + // All connections must start at INIT + // We only set the state if we allocate the connection + // to avoid a race where multiple connection attempts + // are made. + free_slot->set_state(ClientState::INIT); + return free_slot; } void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { From 293d0b90d912b1a7121bb1347e88a03246ccb974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 13:02:02 -0500 Subject: [PATCH 062/597] [core] Document enum class value naming to avoid platform SDK macro collisions (#18241) --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 40381030cb..fa0f61c263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Enumerator names: prefix every value of an `enum class` with the enum name converted to + `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare + names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with + these common names (for example the Realtek SDKs used by LibreTiny define + `#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator + before the compiler sees it, breaking the build and clang-tidy on those platforms. * **Python Idioms:** * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: From 4cf7ad9c6320364fe2e95d0e2d19f37d6464faf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 13:02:27 -0500 Subject: [PATCH 063/597] [ble_device_base] Treat a partially bound merger as unbound (#18235) --- .../ble_device_base/scan_response_merger.cpp | 3 ++- .../ble_device_base/test_scan_response_merger.cpp | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp index 15445cee02..2dd1fd6927 100644 --- a/esphome/components/ble_device_base/scan_response_merger.cpp +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -8,7 +8,8 @@ namespace esphome::ble_device_base { void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, bool raw_only) { - if (this->dispatcher_ == nullptr) + // A partial bind is treated as unbound; never dereference half a binding. + if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr) return; this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, *this->scan_continuous_ ? nullptr : this->log_tag_); diff --git a/tests/components/ble_device_base/test_scan_response_merger.cpp b/tests/components/ble_device_base/test_scan_response_merger.cpp index 013c7bf8f9..ec157715fd 100644 --- a/tests/components/ble_device_base/test_scan_response_merger.cpp +++ b/tests/components/ble_device_base/test_scan_response_merger.cpp @@ -179,5 +179,15 @@ TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) { EXPECT_TRUE(unbound.empty()); } +TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) { + ScanResponseMerger partial; + partial.bind(&this->dispatcher_, nullptr, "test"); + std::vector data(20, 0xAA); + partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size()); + partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0); + partial.flush(); // dropped, not dispatched through half a binding + EXPECT_TRUE(this->raw_.frames.empty()); +} + } // namespace } // namespace esphome::ble_device_base::testing From e4d08a73b0ce00848f71913dbd48a8a072c9a0dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 13:03:01 -0500 Subject: [PATCH 064/597] [ufm01] Prefix PassiveReadResult enumerators to avoid Realtek SDK macro collision (#18240) --- esphome/components/ufm01/ufm01.cpp | 18 +++++++++--------- esphome/components/ufm01/ufm01.h | 6 +++--- tests/components/ufm01/ufm01_frame_test.cpp | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 2859ee4aaa..bafdb5d853 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -329,21 +329,21 @@ PassiveReadResult UFM01Component::continue_passive_read_() { if (this->passive_index_ < PASSIVE_FRAME_SIZE) { if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PENDING; + return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::FAILURE; + return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; } if (!validate_passive_frame(this->passive_frame_)) { log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::FAILURE; + return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; passive_no_id_to_active_frame(this->passive_frame_, active_frame); this->on_active_frame_(active_frame); - return PassiveReadResult::SUCCESS; + return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; } void UFM01Component::loop_startup_() { @@ -410,15 +410,15 @@ void UFM01Component::loop_startup_() { case StartupPhase::PASSIVE_WAIT_REPLY: switch (this->continue_passive_read_()) { - case PassiveReadResult::PENDING: + case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: return; - case PassiveReadResult::SUCCESS: + case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); this->operating_mode_ = OperatingMode::PASSIVE_POLL; this->passive_read_pending_ = false; this->last_poll_ms_ = millis(); return; - case PassiveReadResult::FAILURE: + case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; this->set_startup_phase_(StartupPhase::WAIT); @@ -441,10 +441,10 @@ void UFM01Component::loop_active_stream_() { void UFM01Component::loop_passive_poll_() { if (this->passive_read_pending_) { const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PENDING) + if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::FAILURE) + if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) this->status_set_warning("UFM-01 passive poll failed"); return; } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 6c1da65167..0a39dcc9af 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -40,9 +40,9 @@ enum class StartupPhase : uint8_t { }; enum class PassiveReadResult : uint8_t { - PENDING = 0, - SUCCESS = 1, - FAILURE = 2, + PASSIVE_READ_RESULT_PENDING = 0, + PASSIVE_READ_RESULT_SUCCESS = 1, + PASSIVE_READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp index 82d74b58a5..3b1b148d50 100644 --- a/tests/components/ufm01/ufm01_frame_test.cpp +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -35,7 +35,7 @@ TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -46,7 +46,7 @@ TEST_F(UFM01Test, InvalidPassiveChecksumFails) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::FAILURE); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -56,7 +56,7 @@ TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { @@ -65,7 +65,7 @@ TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { @@ -73,11 +73,11 @@ TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PENDING); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); } } // namespace esphome::ufm01::testing From 9e78a768a21b5b37acb15f07261cb5cc23eb9f9e Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Mon, 10 Aug 2026 21:02:16 +0200 Subject: [PATCH 065/597] [mitsubishi_cn105] Extract top-level hub (#16987) --- .../components/mitsubishi_cn105/__init__.py | 137 ++++++++++ .../components/mitsubishi_cn105/automation.h | 23 ++ .../components/mitsubishi_cn105/climate.py | 240 +++++++++++++----- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 29 ++- .../mitsubishi_cn105/mitsubishi_cn105.h | 29 ++- .../mitsubishi_cn105_climate.cpp | 46 ++-- .../mitsubishi_cn105_climate.h | 26 +- .../mitsubishi_cn105_component.cpp | 34 +++ .../mitsubishi_cn105_component.h | 46 ++++ ...op_level_hub_with_legacy_climate_keys.yaml | 10 + .../mitsubishi_cn105/test_climate.py | 30 +++ .../climate/mitsubishi_cn105_tests.cpp | 8 +- tests/components/mitsubishi_cn105/common.h | 8 +- tests/components/mitsubishi_cn105/common.yaml | 15 +- ...test-legacy-climate-actions.esp32-idf.yaml | 16 ++ ...nt-temperature-min-interval.esp32-idf.yaml | 7 + ...date-legacy-climate-minimal.esp32-idf.yaml | 6 + ...date-legacy-climate-uart-id.esp32-idf.yaml | 7 + ...acy-climate-update-interval.esp32-idf.yaml | 7 + .../validate-top-level-minimal.esp32-idf.yaml | 8 + 20 files changed, 592 insertions(+), 140 deletions(-) create mode 100644 esphome/components/mitsubishi_cn105/automation.h create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h create mode 100644 tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml create mode 100644 tests/component_tests/mitsubishi_cn105/test_climate.py create mode 100644 tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index e69de29bb2..7d5594495a 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -0,0 +1,137 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@crnjan"] +DEPENDENCIES = ["uart"] +DOMAIN = "mitsubishi_cn105" + +CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" +CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" + +mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) + +MitsubishiCN105Component = mitsubishi_ns.class_( + "MitsubishiCN105Component", + cg.Component, + uart.UARTDevice, +) + +SetRemoteTemperatureAction = mitsubishi_ns.class_( + "SetRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +ClearRemoteTemperatureAction = mitsubishi_ns.class_( + "ClearRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MitsubishiCN105Component), + cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, + cv.Optional( + CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" + ): cv.update_interval, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +MITSUBISHI_CN105_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MITSUBISHI_CN105_ID): cv.use_id(MitsubishiCN105Component), + } +) + +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + DOMAIN, + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) +) + + +async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) + cg.add(var.set_parent(parent)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add( + var.set_telemetry_request_min_interval( + config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] + ) + ) + + +REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + } +) + + +@automation.register_action( + f"{DOMAIN}.set_remote_temperature", + SetRemoteTemperatureAction, + REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def remote_temperature_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) + cg.add(var.set_temperature(temperature)) + return var + + +@automation.register_action( + f"{DOMAIN}.clear_remote_temperature", + ClearRemoteTemperatureAction, + CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def clear_temperature_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h new file mode 100644 index 0000000000..879e556f9c --- /dev/null +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -0,0 +1,23 @@ +#pragma once + +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/automation.h" + +namespace esphome::mitsubishi_cn105 { + +template +class SetRemoteTemperatureAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, temperature) + + void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } +}; + +template +class ClearRemoteTemperatureAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 522b9218fc..64475d0e32 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart @@ -7,126 +9,248 @@ from esphome.const import ( CONF_ID, CONF_SUPPORTED_SWING_MODES, CONF_TEMPERATURE, + CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType +from . import ( + CONF_MITSUBISHI_CN105_ID, + DOMAIN, + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] -CODEOWNERS = ["@crnjan"] +_LOGGER = logging.getLogger(__name__) + +# Deprecated legacy climate-owned hub option. Remove in 2027.2.0. CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval" - -mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +CONF_LEGACY_MITSUBISHI_CN105_ID = "legacy_mitsubishi_cn105_id" MitsubishiCN105Climate = mitsubishi_ns.class_( "MitsubishiCN105Climate", climate.Climate, cg.Component, - uart.UARTDevice, + cg.Parented.template(MitsubishiCN105Component), ) -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacySetRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacyClearRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -CONFIG_SCHEMA = ( - climate.climate_schema(MitsubishiCN105Climate) - .extend(uart.UART_DEVICE_SCHEMA) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _has_top_level_hub_config() -> bool: + return DOMAIN in (CORE.raw_config or {}) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "Defining 'climate.mitsubishi_cn105' without a top-level '%s:' hub is " + "deprecated. Declare '%s:' and reference it with '%s:' instead. Will " + "be removed in ESPHome 2027.2.0.", + DOMAIN, + DOMAIN, + CONF_MITSUBISHI_CN105_ID, + ) + + # Add the hidden hub declaration only for legacy climate-owned configs, + # so normal auto-ID resolution does not see it as a top-level hub. + config[CONF_LEGACY_MITSUBISHI_CN105_ID] = cv.declare_id(MitsubishiCN105Component)( + None + ) + return config + + +_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend( + { + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, + } +) + +_HUB_SCHEMA = _BASE_SCHEMA.extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + +# Hub options accepted in the legacy climate-owned configuration. When a +# top-level hub exists, leaving these on the climate is always a migration +# mistake and the generic schema error does not explain where they belong. +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_HUB_KEYS = ( + CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, + CONF_UART_ID, + CONF_UPDATE_INTERVAL, +) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _validate_no_legacy_hub_keys(config: ConfigType) -> ConfigType: + legacy_keys = [key for key in _LEGACY_HUB_KEYS if key in config] + if not legacy_keys: + return config + + keys = ", ".join(f"'{key}'" for key in legacy_keys) + message = f"{keys} must be moved under the top-level '{DOMAIN}:' block" + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in legacy_keys: + message += ( + f"; rename '{CONF_CURRENT_TEMPERATURE_MIN_INTERVAL}' to " + "'telemetry_request_min_interval' there" + ) + raise cv.Invalid(message) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_SCHEMA = ( + _BASE_SCHEMA.extend(uart.UART_DEVICE_SCHEMA) .extend( { - cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, - cv.Optional( - CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" - ): cv.update_interval, - cv.Optional( - CONF_SUPPORTED_SWING_MODES, default="OFF" - ): validate_climate_swing_mode, + cv.Optional(CONF_CURRENT_TEMPERATURE_MIN_INTERVAL): cv.update_interval, + cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval, } ) + .add_extra(_prepare_legacy_hub_config) ) -FINAL_VALIDATE_SCHEMA = cv.All( - uart.final_validate_device_schema( - "mitsubishi_cn105", + +@schema_extractor("schema") +def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: + if config is SCHEMA_EXTRACT: + return _HUB_SCHEMA + if CONF_MITSUBISHI_CN105_ID in config or _has_top_level_hub_config(): + return _HUB_SCHEMA(_validate_no_legacy_hub_keys(config)) + return _LEGACY_SCHEMA(config) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _legacy_final_validate(config: ConfigType) -> ConfigType: + if CONF_MITSUBISHI_CN105_ID in config: + return config + + return uart.final_validate_device_schema( + DOMAIN, require_rx=True, require_tx=True, data_bits=8, parity="EVEN", stop_bits=1, - ) -) + )(config) + + +FINAL_VALIDATE_SCHEMA = _legacy_final_validate async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) - cg.add( - var.set_current_temperature_min_interval( - config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] - ) - ) - - -@automation.register_action( - "climate.mitsubishi_cn105.set_remote_temperature", - SetRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - cv.Required(CONF_TEMPERATURE): cv.templatable( - cv.All( - cv.temperature, - cv.Range(min=8.0, max=39.5), + climate_config = config.copy() + # update_interval configures the protocol hub, not the climate entity. + climate_config.pop(CONF_UPDATE_INTERVAL, None) + await cg.register_component(var, climate_config) + if CONF_MITSUBISHI_CN105_ID in config: + await register_mitsubishi_cn105_device(var, config) + else: + # Legacy climate-owned hub compatibility. Remove in 2027.2.0. + parent = cg.new_Pvariable(config[CONF_LEGACY_MITSUBISHI_CN105_ID]) + await cg.register_component(parent, config) + await uart.register_uart_device(parent, config) + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in config: + cg.add( + parent.set_telemetry_request_min_interval( + config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] ) - ), - } - ), + ) + cg.add(var.set_parent(parent)) + cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + } +) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +@automation.register_action( + f"climate.{DOMAIN}.set_remote_temperature", + LegacySetRemoteTemperatureAction, + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def set_remote_temperature_action_to_code( +async def legacy_remote_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.set_remote_temperature' action is deprecated. Use " + "'%s.set_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) cg.add(var.set_temperature(temperature)) - return var +# Legacy climate action compatibility. Remove in 2027.2.0. @automation.register_action( - "climate.mitsubishi_cn105.clear_remote_temperature", - ClearRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - } - ), + f"climate.{DOMAIN}.clear_remote_temperature", + LegacyClearRemoteTemperatureAction, + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def clear_remote_temperature_action_to_code( +async def legacy_clear_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " + "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 4782a2ef93..415de34166 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,8 +1,9 @@ +#include "mitsubishi_cn105.h" + #include #include #include #include -#include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { @@ -25,7 +26,7 @@ static constexpr std::array CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01}; static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42; static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62; static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02; -static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; +static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; @@ -229,8 +230,8 @@ void MitsubishiCN105::did_transition_(State to) { case State::STATUS_UPDATED: { if (this->pending_updates_.any() && this->is_status_initialized()) { this->set_state_(State::APPLYING_SETTINGS); - } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { - this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP; + } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_telemetry_()) { + this->current_status_msg_type_ = STATUS_MSG_TELEMETRY; this->set_state_(State::UPDATING_STATUS); } else { this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); @@ -264,16 +265,16 @@ void MitsubishiCN105::did_transition_(State to) { } } -bool MitsubishiCN105::should_request_room_temperature_() const { - if (!this->is_room_temperature_enabled()) { +bool MitsubishiCN105::should_request_telemetry_() const { + if (!this->is_telemetry_polling_enabled()) { return false; } - if (!this->last_room_temperature_update_ms_.has_value()) { + if (!this->last_telemetry_update_ms_.has_value()) { return true; } - return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_; + return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { @@ -327,7 +328,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature || previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode; - if (this->is_room_temperature_enabled()) { + if (this->is_telemetry_polling_enabled()) { changed |= previous.room_temperature != this->status_.room_temperature; } @@ -339,8 +340,8 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay case STATUS_MSG_SETTINGS: return this->parse_status_settings_(payload, len); - case STATUS_MSG_ROOM_TEMP: - return this->parse_status_room_temperature_(payload, len); + case STATUS_MSG_TELEMETRY: + return this->parse_status_telemetry_(payload, len); default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -384,14 +385,14 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) return true; } -bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) { +bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) { if (len <= 5) { - ESP_LOGVV(TAG, "RX room temperature payload too short"); + ESP_LOGVV(TAG, "RX telemetry payload too short"); return false; } this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_room_temperature_update_ms_ = get_loop_time_ms(); + this->last_telemetry_update_ms_ = get_loop_time_ms(); return true; } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 742d8e18a9..3169359290 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,9 +1,10 @@ #pragma once +#include "esphome/components/uart/uart.h" +#include "esphome/core/finite_set_mask.h" + #include #include -#include "esphome/components/uart/uart.h" -#include "esphome/core/finite_set_mask.h" namespace esphome::mitsubishi_cn105 { @@ -70,16 +71,16 @@ class MitsubishiCN105 { uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } - uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; } - bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; } - void set_room_temperature_min_interval(uint32_t interval_ms) { - this->room_temperature_min_interval_ms_ = interval_ms; + uint32_t get_telemetry_request_min_interval() const { return this->telemetry_request_min_interval_ms_; } + bool is_telemetry_polling_enabled() const { return this->telemetry_request_min_interval_ms_ != SCHEDULER_DONT_RUN; } + void set_telemetry_request_min_interval(uint32_t interval_ms) { + this->telemetry_request_min_interval_ms_ = interval_ms; } const Status &status() const { return this->status_; } bool is_status_initialized() const { - return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature) - : !std::isnan(this->status_.target_temperature); + return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) + : !std::isnan(this->status_.target_temperature); } void set_power(bool power_on); @@ -150,10 +151,10 @@ class MitsubishiCN105 { bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_room_temperature_(const uint8_t *payload, size_t len); + bool parse_status_telemetry_(const uint8_t *payload, size_t len); void send_packet_(const uint8_t *packet, size_t len); void update_status_(); - bool should_request_room_temperature_() const; + bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); @@ -162,11 +163,15 @@ class MitsubishiCN105 { static const LogString *state_to_string(State state); uart::UARTDevice &device_; + // Default 1s; legacy climate-owned hub compatibility relies on this when update_interval is omitted. + // Remove legacy note in 2027.2.0. uint32_t update_interval_ms_{1000}; uint32_t status_update_wait_credit_ms_{0}; uint32_t operation_start_ms_{0}; - uint32_t room_temperature_min_interval_ms_{60000}; - std::optional last_room_temperature_update_ms_; + // Default 60s; legacy climate-owned hub compatibility relies on this when current_temperature_min_interval is + // omitted. Remove legacy note in 2027.2.0. + uint32_t telemetry_request_min_interval_ms_{60000}; + std::optional last_telemetry_update_ms_; Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index afffe7ea5e..13e02668d1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,5 +1,5 @@ -#include #include "mitsubishi_cn105_climate.h" + #include "esphome/core/log.h" namespace esphome::mitsubishi_cn105 { @@ -50,25 +50,11 @@ static constexpr std::optional reverse_map_lookup(const std::arrayhp_.is_room_temperature_enabled()) { - ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", - this->hp_.get_room_temperature_min_interval()); - } else { - ESP_LOGCONFIG(TAG, " Current temperature: DISABLED"); - } - ESP_LOGCONFIG(TAG, - " Update interval: %" PRIu32 " ms\n" - " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", - this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), - LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); -} +void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); } -void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } - -void MitsubishiCN105Climate::loop() { - if (this->hp_.update()) { +void MitsubishiCN105Climate::setup() { + this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); + if (this->parent_->is_status_initialized()) { this->apply_values_(); } } @@ -90,7 +76,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.set_visual_max_temperature(31.0f); traits.set_visual_temperature_step(1.0f); - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); traits.set_visual_current_temperature_step(0.5f); } @@ -100,20 +86,20 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->hp_.set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(*target_temperature); } if (const auto mode = call.get_mode()) { if (*mode == climate::CLIMATE_MODE_OFF) { - this->hp_.set_power(false); + this->parent_->set_power(false); } else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) { - this->hp_.set_power(true); - this->hp_.set_mode(*mapped); + this->parent_->set_power(true); + this->parent_->set_mode(*mapped); } } if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) { - this->hp_.set_fan_mode(*fan_mode); + this->parent_->set_fan_mode(*fan_mode); } if (const auto swing_mode = call.get_swing_mode()) { @@ -140,24 +126,24 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { - this->hp_.set_vane_mode(vane); + this->parent_->set_vane_mode(vane); } if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { - this->hp_.set_wide_vane_mode(wide); + this->parent_->set_wide_vane_mode(wide); } } - if (this->hp_.is_status_initialized()) { + if (this->parent_->is_status_initialized()) { this->apply_values_(); } } void MitsubishiCN105Climate::apply_values_() { - const auto &status = this->hp_.status(); + const auto &status = this->parent_->status(); this->target_temperature = status.target_temperature; - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { this->current_temperature = status.room_temperature; } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index c83a5519c1..5341c2d2d9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -1,51 +1,47 @@ #pragma once +#include "mitsubishi_cn105_component.h" +#include "mitsubishi_cn105.h" + #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" -#include "esphome/components/uart/uart.h" -#include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { -class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice { +class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented { public: - explicit MitsubishiCN105Climate() : hp_(*this) {} - void setup() override; - void loop() override; void dump_config() override; climate::ClimateTraits traits() override; void control(const climate::ClimateCall &call) override; - void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } - void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); } - - void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } - void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } - void set_supported_swing_mode(climate::ClimateSwingMode mode); + // Legacy climate action compatibility. Remove in 2027.2.0. + void set_remote_temperature(float temperature) { this->parent_->set_remote_temperature(temperature); } + void clear_remote_temperature() { this->parent_->clear_remote_temperature(); } protected: void apply_values_(); - MitsubishiCN105 hp_; climate::ClimateSwingModeMask supported_swing_modes_{}; MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO}; MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER}; }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class SetRemoteTemperatureAction : public Action, public Parented { +class LegacySetRemoteTemperatureAction : public Action, public Parented { public: TEMPLATABLE_VALUE(float, temperature) void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class ClearRemoteTemperatureAction : public Action, public Parented { +class LegacyClearRemoteTemperatureAction : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } }; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp new file mode 100644 index 0000000000..166e7fbf88 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -0,0 +1,34 @@ +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/log.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105"; + +void MitsubishiCN105Component::dump_config() { + ESP_LOGCONFIG(TAG, "Mitsubishi CN105:"); + if (this->hp_.is_telemetry_polling_enabled()) { + ESP_LOGCONFIG(TAG, " Telemetry polling min interval: %" PRIu32 " ms", + this->hp_.get_telemetry_request_min_interval()); + } else { + ESP_LOGCONFIG(TAG, " Telemetry polling: DISABLED"); + } + ESP_LOGCONFIG(TAG, + " Update interval: %" PRIu32 " ms\n" + " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", + this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), + LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); +} + +void MitsubishiCN105Component::setup() { this->hp_.initialize(); } + +void MitsubishiCN105Component::loop() { + if (this->hp_.update()) { + this->status_callback_.call(); + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h new file mode 100644 index 0000000000..2319ea7c54 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -0,0 +1,46 @@ +#pragma once + +#include "mitsubishi_cn105.h" + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105Component : public Component, public uart::UARTDevice { + public: + explicit MitsubishiCN105Component() : hp_(*this) {} + + void setup() override; + void loop() override; + void dump_config() override; + + void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } + void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + + void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } + void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + + void set_power(bool power_on) { this->hp_.set_power(power_on); } + void set_target_temperature(float target_temperature) { this->hp_.set_target_temperature(target_temperature); } + void set_mode(MitsubishiCN105::Mode mode) { this->hp_.set_mode(mode); } + void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } + void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } + void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + + const MitsubishiCN105::Status &status() const { return this->hp_.status(); } + bool is_status_initialized() const { return this->hp_.is_status_initialized(); } + bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + + protected: + MitsubishiCN105 hp_; + CallbackManager status_callback_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml b/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml new file mode 100644 index 0000000000..0226d680a4 --- /dev/null +++ b/tests/component_tests/mitsubishi_cn105/fixtures/top_level_hub_with_legacy_climate_keys.yaml @@ -0,0 +1,10 @@ +mitsubishi_cn105: + id: ac_hub + +climate: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac_hub + name: AC + current_temperature_min_interval: 30s + uart_id: uart_bus + update_interval: 10s diff --git a/tests/component_tests/mitsubishi_cn105/test_climate.py b/tests/component_tests/mitsubishi_cn105/test_climate.py new file mode 100644 index 0000000000..e4e3da9c7f --- /dev/null +++ b/tests/component_tests/mitsubishi_cn105/test_climate.py @@ -0,0 +1,30 @@ +"""Tests for Mitsubishi CN105 climate configuration migration diagnostics.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mitsubishi_cn105 import climate +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.yaml_util import load_yaml + + +def test_top_level_hub_rejects_leftover_legacy_climate_keys( + component_fixture_path: Callable[[str], Path], +) -> None: + config = load_yaml( + component_fixture_path("top_level_hub_with_legacy_climate_keys.yaml") + ) + CORE.raw_config = config + + with pytest.raises(cv.Invalid) as exc_info: + climate.CONFIG_SCHEMA(config["climate"][0]) + + message = str(exc_info.value) + assert "'current_temperature_min_interval'" in message + assert "'uart_id'" in message + assert "'update_interval'" in message + assert "top-level 'mitsubishi_cn105:' block" in message + assert "'telemetry_request_min_interval'" in message diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index ef3cdd0fff..7703b02fcd 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -75,7 +75,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING); - // Now fetch room temperature (0x03) + // Now fetch telemetry (0x03) EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS); EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A)); @@ -84,11 +84,11 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { // Clear TX bytes. ctx.uart.tx.clear(); - // Room temperature response + // Telemetry response ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5}); - // Room temperature should still have initial value + // Room temperature from telemetry should still have initial value EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan()); ctx.sut.set_current_time(400); @@ -97,7 +97,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { EXPECT_TRUE(ctx.uart.rx.empty()); EXPECT_TRUE(ctx.sut.is_status_initialized()); - // Check room temperature we just read from received package + // Check room temperature we just read from telemetry package EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f); EXPECT_TRUE(ctx.uart.tx.empty()); diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 45f7b65289..a14043c737 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" namespace esphome::mitsubishi_cn105::testing { @@ -65,11 +66,16 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { public: + TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); } + using MitsubishiCN105Climate::apply_values_; using MitsubishiCN105Climate::last_non_swing_vane_mode_; using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; - MitsubishiCN105::Status &status() { return static_cast(this->hp_).status_; } + MitsubishiCN105::Status &status() { return const_cast(this->component_.status()); } + + protected: + MitsubishiCN105Component component_; }; } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5b9c3aaaf6..5966523b34 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -1,17 +1,20 @@ +mitsubishi_cn105: + id: ac + uart_id: uart_bus + update_interval: 30s + telemetry_request_min_interval: 120s + climate: - platform: mitsubishi_cn105 - id: ac + mitsubishi_cn105_id: ac name: "AC Test" - uart_id: uart_bus - update_interval: 30s - current_temperature_min_interval: 120s supported_swing_modes: BOTH esphome: on_boot: then: - - climate.mitsubishi_cn105.set_remote_temperature: + - mitsubishi_cn105.set_remote_temperature: id: ac temperature: 22.0 - - climate.mitsubishi_cn105.clear_remote_temperature: + - mitsubishi_cn105.clear_remote_temperature: id: ac diff --git a/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml b/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml new file mode 100644 index 0000000000..247568cfc3 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test-legacy-climate-actions.esp32-idf.yaml @@ -0,0 +1,16 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + id: ac + name: "AC Test" + +esphome: + on_boot: + then: + - climate.mitsubishi_cn105.set_remote_temperature: + id: ac + temperature: 22.0 + - climate.mitsubishi_cn105.clear_remote_temperature: + id: ac diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml new file mode 100644 index 0000000000..a2abaf8b9b --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-current-temperature-min-interval.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + current_temperature_min_interval: 30s diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml new file mode 100644 index 0000000000..0ef70b6535 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-minimal.esp32-idf.yaml @@ -0,0 +1,6 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml new file mode 100644 index 0000000000..065d2b5495 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-uart-id.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + uart_id: uart_bus diff --git a/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml new file mode 100644 index 0000000000..2e8f714f52 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-legacy-climate-update-interval.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + update_interval: 30s diff --git a/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml b/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml new file mode 100644 index 0000000000..03f05da5f4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/validate-top-level-minimal.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +mitsubishi_cn105: + +climate: + - platform: mitsubishi_cn105 + name: "AC Test" From 207ae2e4cb53b29eb566c3e94861a583ac8a016d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 14:02:37 -0500 Subject: [PATCH 066/597] [bk72xx_ble] Support active scanning by packing the GAPM start command (#18169) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bdraco <663432+bdraco@users.noreply.github.com> --- esphome/components/bk72xx_ble/bdk_scan.cpp | 119 ++++++++ esphome/components/bk72xx_ble/bdk_scan.h | 51 ++++ esphome/components/bk72xx_ble/bk72xx_ble.cpp | 281 ++++++++++++++++-- esphome/components/bk72xx_ble/bk72xx_ble.h | 68 ++++- .../components/bk72xx_ble_tracker/__init__.py | 5 + .../bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 232 +++++++++++---- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 80 ++--- .../components/ble_device_base/__init__.py | 11 +- esphome/components/ble_device_base/ble_hub.h | 6 +- .../bluetooth_connection/__init__.py | 1 + .../components/bluetooth_proxy/__init__.py | 25 +- .../components/esp32_ble_tracker/__init__.py | 4 +- .../components/ln882h_ble_tracker/__init__.py | 2 +- .../components/rp2_ble_tracker/__init__.py | 4 +- .../config/test_automations.yaml | 1 + .../test_automations_codegen.py | 2 + .../test_scan_parameter_validation.py | 16 +- .../bluetooth_proxy/test_platform_gates.py | 7 +- .../validate-passive.bk72xx-ard.yaml | 8 + .../bluetooth_proxy/validate.bk72xx-ard.yaml | 11 + 20 files changed, 754 insertions(+), 180 deletions(-) create mode 100644 esphome/components/bk72xx_ble/bdk_scan.cpp create mode 100644 esphome/components/bk72xx_ble/bdk_scan.h create mode 100644 tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml create mode 100644 tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp new file mode 100644 index 0000000000..bd4e51d9b7 --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -0,0 +1,119 @@ +// Every SDK call the scan reconciler makes. The BDK's own start hardcodes +// passive (the active bit is commented out in both stacks), so +// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field +// the SDK's app_ble_start_scaning() except that prop takes the mode, armed +// through the SDK's own operation bookkeeping. The component pins +// beken-bdk 3.0.78; the static asserts catch a layout change on a bump. + +#include "bdk_scan.h" + +#ifdef USE_BK72XX_BLE + +// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") + +extern "C" { +#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, + // app_ble_actv_state_get, app_ble_env_state_get, + // app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX, + // bk_ble_* (via ble_api_5_x.h) +#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send +#if __has_include("gapm_msg.h") +#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_* +#else +#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name +#endif +} + +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +// Pin the SDK surface this file depends on: a beken-bdk bump that moves these +// must fail the build, not corrupt the kernel message. +static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) && + sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4, + "beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() " + "against the SDK's app_ble_start_scaning()"); +static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX, + "beken-bdk activity sentinel changed; revalidate the scan reconciler"); +static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 && + GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5), + "beken-bdk GAPM report info changed; revalidate the tracker's demux constants"); + +bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; } + +BdkActivityState bdk_scan_state(uint8_t activity_idx) { + if (activity_idx == INVALID_ACTIVITY_IDX) + return BdkActivityState::IDLE; + switch (app_ble_actv_state_get(activity_idx)) { + case ACTV_IDLE: + return BdkActivityState::IDLE; + case ACTV_SCAN_CREATED: + return BdkActivityState::CREATED; + case ACTV_SCAN_STARTED: + return BdkActivityState::STARTED; + default: + return BdkActivityState::OTHER; + } +} + +uint8_t bdk_scan_acquire_activity() { + uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); + if (idx == INVALID_ACTIVITY_IDX) + ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); + return idx; +} + +BdkOpResult bdk_scan_create(uint8_t activity_idx) { + ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + if (ret == ERR_BLE_STATUS) + return BdkOpResult::BUSY; + ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast(ret)); + return BdkOpResult::FAILED; +} + +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) { + app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr); + struct gapm_activity_start_cmd *cmd = + KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd); + if (cmd == nullptr) { + app_ble_reset(); // the SDK's own failure path for an unsent operation + ESP_LOGE(TAG, "Scan start failed: kernel message allocation"); + return BdkOpResult::FAILED; + } + cmd->operation = GAPM_START_ACTIVITY; + cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx; + cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER; + cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0); + cmd->u_param.scan_param.scan_param_1m.scan_intv = interval; + cmd->u_param.scan_param.scan_param_1m.scan_wd = window; + cmd->u_param.scan_param.scan_param_coded.scan_intv = 0; + cmd->u_param.scan_param.scan_param_coded.scan_wd = 0; + cmd->u_param.scan_param.dup_filt_pol = 0; + cmd->u_param.scan_param.rsvd = 0; + cmd->u_param.scan_param.duration = 0; // scan until stopped + cmd->u_param.scan_param.period = 10; // matches the SDK's passive start + kernel_msg_send(cmd); + return BdkOpResult::OK; +} + +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { + ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr); + *err_out = static_cast(ret); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + // DEBUG on purpose: the reconciler WARNs once per streak and the stuck + // ERROR carries this code — a per-retry ERROR would be unbounded. + ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast(ret)); + return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // !CLANG_TIDY && ble_api.h +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bdk_scan.h b/esphome/components/bk72xx_ble/bdk_scan.h new file mode 100644 index 0000000000..47bce2449d --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include + +namespace esphome::bk72xx_ble { + +/// Activity index value marking "no scan activity", the BDK's own convention +/// (asserted against its symbol in bdk_scan.cpp). +inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF; + +/// Scan-relevant controller activity states, read live from the SDK. +enum class BdkActivityState : uint8_t { + IDLE, ///< No activity (or one whose create failed). + CREATED, ///< Created but not started. + STARTED, ///< Scanning. + OTHER, ///< A non-scan or transitional state; settles on a later read. +}; + +/// Outcome of a BDK scan operation request. +enum class BdkOpResult : uint8_t { + OK, ///< Accepted; completion is asynchronous. + BUSY, ///< Another controller operation is in flight; retry later. + FAILED, ///< Rejected. +}; + +/// True when no controller operation is in flight (APP_BLE_READY). +bool bdk_scan_ready(); +/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE. +BdkActivityState bdk_scan_state(uint8_t activity_idx); +/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free. +uint8_t bdk_scan_acquire_activity(); +/// Create the scan activity (asynchronous); started once CREATED is observed. +BdkOpResult bdk_scan_create(uint8_t activity_idx); +/// Start a created activity: the packed GAPM start, taking the scan mode the +/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the +/// kernel message could not be allocated (the armed SDK operation is rolled +/// back). +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active); +/// Release the activity: delete when never started (a stop would be +/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED +/// on any other error; err_out receives the SDK code (0 on success). +/// Teardown is asynchronous — observe IDLE to confirm. +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out); + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 69c7df0b96..954cb9fe87 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -5,7 +5,8 @@ // talks to the Beken BDK BLE stack: // - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), // - the controller BLE address, -// - the raw controller scan primitives (bk_ble_scan_start/stop), +// - the scan reconciler (request, pacing, bring-up budget) over the +// bdk_scan surface, // - the scan-report ring: the BDK notice callback (BLE task) takes a report // from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains, // dispatches on the main task and returns reports to the pool — the same @@ -20,10 +21,13 @@ #include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE +#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release) + #ifdef USE_BK72XX_BLE #include +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" // get_mac_address_raw() #include "esphome/core/log.h" @@ -57,9 +61,8 @@ // are C headers consumed from C++ (a standard C-header-from-C++ pattern). // --------------------------------------------------------------------------- extern "C" { -#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb, - // app_ble_get_idle_actv_idx_handle, struct scan_param, - // recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV +#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t, + // BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp) #ifdef BK72XX_BLE_HAS_COMMON_BDADDR #include "common_bt_defines.h" // struct bd_addr // The controller's public BLE address, populated by the BDK during ble_entry(). @@ -76,6 +79,12 @@ namespace esphome::bk72xx_ble { static const char *const TAG = "bk72xx_ble"; +static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops +static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release +static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED +static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence +static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED) + // The BDK notice callback is a plain C function pointer with no user argument, // so it reaches the (single) component instance through a file-static pointer. static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -95,12 +104,12 @@ static void ble_notice_callback(ble_notice_t notice, void *param) { const recv_adv_t *info = reinterpret_cast(param); // rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for // a signed dBm value packed in a uint8_t). - s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, info->data, - info->data_len); + s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, + static_cast(info->evt_type), info->data, info->data_len); } -void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, - uint16_t data_len) { +void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, + const uint8_t *data, uint16_t data_len) { BLEScanReport *report = this->report_pool_.allocate(); if (report == nullptr) { // Pool exhausted — the queue is full; count and drop. @@ -110,6 +119,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add memcpy(report->mac, mac, 6); report->rssi = rssi; report->addr_type = addr_type; + report->evt_type = evt_type; report->data_len = (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); memcpy(report->data, data, report->data_len); @@ -123,6 +133,9 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add void BK72xxBLE::setup() { s_ble = this; + // The report pool grows lazily on purpose: the BDK notice callback runs in + // task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic + // stays far below the pool cap, so not warming contains RAM. // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before // the stack is up (it is re-read once ble_entry() has run). this->resolve_mac_(); @@ -173,6 +186,30 @@ void BK72xxBLE::enable() { } void BK72xxBLE::loop() { + // Keep reconciling toward the requested scan state (e.g. complete a stop + // that arrived while a controller operation was in flight), and re-check a + // settled scan at low frequency: a controller-side drop re-enters the + // bring-up, and the budget's FAILED feeds the tracker's recovery. + // Keep driving until settled: any PENDING, plus a terminal stop whose slot + // must still be freed. A FAILED scan request is the one combination not + // re-driven here — that belongs to the tracker's backoff. + const uint32_t pump_now = App.get_loop_component_start_time(); + if (this->last_result_ == ScanOpResult::PENDING || + (!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) { + const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED) + ? RECONCILE_REJECTED_RETRY_MS + : RECONCILE_RETRY_MS; + if (pump_now - this->last_advance_ms_ >= gate) + this->advance_(); + } else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED && + pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) { + // Re-check a settled scan; scan_start() refills the bring-up budget. + // WARN: the only report of a drop that recovers inside its budget. + if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) != + ScanOpResult::SETTLED) + ESP_LOGW(TAG, "Controller dropped the scan; restarting"); + } + // Drain the lock-free ring filled by the BLE task; all per-report work runs // here on the main task, then the report returns to the pool. BLEScanReport *report = this->report_queue_.pop(); @@ -248,44 +285,228 @@ void BK72xxBLE::resolve_mac_() { } // --------------------------------------------------------------------------- -// Controller scan primitives +// Scan reconciler // --------------------------------------------------------------------------- -bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) { +// Episode boundary: fresh teardown deadline and error bookkeeping. +void BK72xxBLE::reset_teardown_episode_() { + this->teardown_since_ms_ = 0; + this->restarting_ = false; + this->last_release_err_ = 0; +} + +ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) this->enable(); - if (this->scan_actv_idx_ != 0xFF) { - // Already scanning — stop first so this call cleanly restarts with the new - // parameters (the BDK cannot start a second scan on a busy activity). - this->scan_stop(); + const ScanParams params{active, interval, window}; + // A new episode refills the budget and gets a fresh teardown deadline; a + // re-call observing an in-flight bring-up (last result PENDING) must not. + if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) { + this->pending_since_ms_ = App.get_loop_component_start_time(); + this->reset_teardown_episode_(); } + this->scan_wanted_ = true; + this->requested_ = params; + return this->advance_(); +} - struct scan_param sp; - memset(&sp, 0, sizeof(sp)); - sp.channel_map = 7; // advertising channels 37/38/39 - sp.interval = interval; - sp.window = window; +void BK72xxBLE::scan_stop() { + if (this->scan_wanted_) { + // A stamp inherited from a stuck restart would fail the stop on its + // first advance. + this->reset_teardown_episode_(); + } + this->scan_wanted_ = false; + this->advance_(); +} - this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); - if (this->scan_actv_idx_ == 0xFF) { - ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); +bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) { + // millis() on both sides: the loop clock is frozen while this blocks. + const uint32_t start = millis(); + while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) { + if (millis() - start >= timeout_ms) + return false; + delay(RECONCILE_RETRY_MS); + this->advance_(); + } + return this->last_result_ == ScanOpResult::SETTLED; +} + +// Teardown is asynchronous: the handle is kept until an IDLE observation +// confirms the radio is idle. A rejection WARNs once per failure streak and +// widens the pump gate; the epilogue owns the stuck-teardown deadline. +void BK72xxBLE::release_activity_(BdkActivityState state) { + const BdkOpResult result = + bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_); + if (result == BdkOpResult::OK) { + this->release_warned_ = false; + return; + } + if (!this->release_warned_) { + // A hard error carries its code immediately; the 30 s stuck ERROR follows + // if it persists. + if (result == BdkOpResult::FAILED) { + ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_); + } else { + ESP_LOGW(TAG, "Scan activity release rejected; retrying"); + } + this->release_warned_ = true; + } +} + +// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged +// each interval) and report stuck. +bool BK72xxBLE::teardown_stuck_(uint32_t now) { + if (this->teardown_since_ms_ == 0) { + this->teardown_since_ms_ = now; + this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline return false; } - ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr); - if (ret != ERR_SUCCESS) { - ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast(ret)); - this->scan_actv_idx_ = 0xFF; + if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS) return false; + if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) { + if (this->last_release_err_ != 0) { + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_); + } else { + // No rejected release this episode: stuck waiting on the controller. + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)"); + } + this->teardown_stuck_log_ms_ = now; } return true; } -void BK72xxBLE::scan_stop() { - if (this->scan_actv_idx_ != 0xFF) { - bk_ble_scan_stop(this->scan_actv_idx_, nullptr); - this->scan_actv_idx_ = 0xFF; +// One SDK operation per call toward the latched request; controller state is +// read live each time (it changes on the BLE task, so nothing is mirrored). +// The epilogue owns all deadlines and episode bookkeeping. +ScanOpResult BK72xxBLE::advance_() { + if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + // Nothing to do; also keeps SDK reads off the pre-enable() path. + this->last_result_ = ScanOpResult::SETTLED; + return ScanOpResult::SETTLED; } + const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_); + const bool ready = bdk_scan_ready(); + ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready); + + const uint32_t now = App.get_loop_component_start_time(); + this->last_advance_ms_ = now; + if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) { + // Any teardown episode is over (IDLE observed with the controller + // settled, or e.g. a mode flip that settled back without ever reaching + // IDLE). An IDLE read while an operation is in flight proves nothing — + // a stop deferred there must keep its episode running. + this->reset_teardown_episode_(); + this->release_warned_ = false; + } + if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) { + // The mode-change release is observed complete; the rest is a normal + // bring-up on a fresh budget. + this->restarting_ = false; + this->pending_since_ms_ = now; + } + // Not chained to the clear above: a bring-up waiting at IDLE (create still + // in flight) must keep spending its budget. + if (result == ScanOpResult::PENDING) { + if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) { + // A downed radio spends the bring-up budget; exhausting it hands + // recovery to the tracker's backoff. + if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) { + ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start"); + result = ScanOpResult::FAILED; + } + } else { + // A teardown is pending: a stop, or a mode-change release still in + // flight (restarting_); either way the bring-up budget waits. + if (this->scan_wanted_) + this->pending_since_ms_ = now; + if (this->teardown_stuck_(now)) { + // Terminal for stop AND restart: the tracker's backoff owns recovery + // (a stop's release keeps re-driving from loop(); a restart is + // re-requested through scan_start() with a fresh deadline). + result = ScanOpResult::FAILED; + } + } + } + this->last_result_ = result; + return result; +} + +ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::IDLE && ready) { + // Fully torn down (or never created): the radio is idle. IDLE is trusted + // only when the controller is settled — mid-create the slot still reads + // IDLE, and dropping the handle then would leak the activity once the + // create lands. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::SETTLED; + } + if (!ready) { + // Acting mid-operation could delete an activity whose start lands + // afterwards, leaking the slot with the radio on; wait. + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan stop deferred (controller busy)"); + return ScanOpResult::PENDING; + } + // Settled, so CREATED unambiguously means "never started". + this->release_activity_(state); + return ScanOpResult::PENDING; // confirmed once IDLE is observed +} + +ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::STARTED) { + if (this->applied_ == this->requested_) + return ScanOpResult::SETTLED; + // Running with different mode or parameters: tear down (the SDK stop + // chain also deletes the activity) and recreate on a later advance. + if (ready) { + this->release_activity_(state); + // Invalidate so a flip back to the old params cannot SETTLE against the + // activity being deleted (interval 0 never matches a real request). + this->applied_.interval = 0; + this->restarting_ = true; + } + return ScanOpResult::PENDING; + } + if (!ready) { + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan start deferred (controller busy)"); + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::CREATED) { + // Fire-and-forget: SETTLED only once a later advance observes the scan + // running, so a rejected start is retried rather than silently dead. On + // failure the created activity is intact; keep the handle. + if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window, + this->requested_.active) != BdkOpResult::OK) + return ScanOpResult::FAILED; + this->applied_ = this->requested_; + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::OTHER) + return ScanOpResult::PENDING; // transitional; settles on a later read + + // IDLE and ready: acquire a slot and create. A kept index is deliberately + // reused: SDK delete returns the slot to idle and create requires an idle + // slot, so it equals a fresh acquire — while clearing here would orphan a + // create still in flight (the BUSY race below). + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + this->scan_activity_idx_ = bdk_scan_acquire_activity(); + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) + return ScanOpResult::FAILED; + } + switch (bdk_scan_create(this->scan_activity_idx_)) { + case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot + case BdkOpResult::OK: + return ScanOpResult::PENDING; + case BdkOpResult::FAILED: + break; + } + // Safe to clear (unlike BUSY): acquire is a pure search, so a rejected + // create leaves the slot IDLE for re-acquire. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::FAILED; } } // namespace esphome::bk72xx_ble diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 4e615af159..7646f17161 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -11,6 +11,8 @@ #include +#include "bdk_scan.h" + namespace esphome::bk72xx_ble { enum class BLEComponentState : uint8_t { @@ -19,11 +21,32 @@ enum class BLEComponentState : uint8_t { ACTIVE, }; +/// Outcome of one reconciliation step. +enum class ScanOpResult : uint8_t { + SETTLED, ///< The request is reached: scan observed running, or stopped + ///< with the activity fully released. + PENDING, ///< A step is in flight; loop() keeps advancing — call + ///< scan_start() again to learn the outcome. + FAILED, ///< The controller rejected a step; retry later. +}; + +/// One scan request: mode plus timing, in BLE units (0.625 ms). +struct ScanParams { + bool active; + uint16_t interval; + uint16_t window; + bool operator==(const ScanParams &) const = default; +}; + /// One advertisement report from the controller. struct BLEScanReport { uint8_t mac[6]; // LSB-first, as the controller delivers it int8_t rssi; // signed dBm uint8_t addr_type; + // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type + // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the + // tracker's merger tell the two frames apart. + uint8_t evt_type; uint8_t data_len; // bytes valid in data[] uint8_t data[62]; // legacy advertisement (31) + scan response (31) @@ -69,18 +92,33 @@ class BK72xxBLE final : public Component { void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } #endif - /// Start the controller scan. Interval/window are in BLE units (0.625 ms). - /// Enables the stack first if needed. Returns false on controller failure. - bool scan_start(uint16_t interval, uint16_t window); - /// Stop the controller scan (no-op when not scanning). + /// Request a scan (interval/window in 0.625 ms BLE units); enables the + /// stack first if needed. PENDING until the scan is observed running — + /// loop() keeps advancing, call again to learn the outcome. + ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active); + /// Request the scanner stopped and the activity released; steps that + /// cannot run yet are completed from loop(). void scan_stop(); + /// Drive a requested stop until the radio is observed idle, bounded by + /// timeout_ms (for OTA). Returns false if it still has not settled. + bool flush_pending_stop(uint32_t timeout_ms); + /// Last reconciliation outcome; on FAILED the consumer's retry policy owns + /// recovery. + ScanOpResult last_scan_result() const { return this->last_result_; } /// Internal: buffer one controller report (BDK notice callback, BLE task /// context — bounded copy under the scheduler lock, nothing else). - void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data, + uint16_t data_len); protected: void resolve_mac_(); + ScanOpResult advance_(); + ScanOpResult advance_stop_(BdkActivityState state, bool ready); + ScanOpResult advance_start_(BdkActivityState state, bool ready); + bool teardown_stuck_(uint32_t now); + void reset_teardown_episode_(); + void release_activity_(BdkActivityState state); #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT // Codegen-sized: no heap allocation, no std::vector template instantiation — @@ -95,10 +133,24 @@ class BK72xxBLE final : public Component { // allocate() returns nullptr before push() can fail. This prevents leaking a // pool slot on a failed push and keeps release() off the producer path. esphome::EventPool report_pool_; - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) - uint8_t scan_actv_idx_{0xFF}; - BLEComponentState state_{BLEComponentState::STATE_OFF}; + // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. + uint32_t last_advance_ms_{0}; + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; + bool scan_wanted_{false}; // the latched request is to scan (vs stopped) + bool release_warned_{false}; // gates the release WARN; widens the pump gate + bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released bool enable_on_boot_{false}; + // PENDING means advance_() has more to do; loop() drives it, paced and + // (for a bring-up) bounded. + ScanOpResult last_result_{ScanOpResult::SETTLED}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; }; } // namespace esphome::bk72xx_ble diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 7fefb310cd..96b3536601 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -25,6 +25,7 @@ from esphome.components.ble_device_base import automation as ble_automation from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW import esphome.config_validation as cv from esphome.const import ( + CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_ID, @@ -146,6 +147,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. cg.add_define("USE_BK72XX_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the BDK delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -164,6 +168,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) for conf in config.get(CONF_ON_BLE_ADVERTISE, []): await ble_automation.advertise_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index a58561f2de..a312d2496f 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -9,10 +9,9 @@ #include "bk72xx_ble_tracker.h" -#include #include -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::bk72xx_ble_tracker { @@ -27,6 +26,15 @@ static const char *const TAG = "bk72xx_ble_tracker"; // a single WARN is emitted when the retry interval first saturates. static constexpr uint32_t SCAN_START_RETRY_MS = 1000; static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s +// Stable-run time before the failure streak clears; reset-on-start would keep +// a flapping controller at the 1 s gate. +static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000; + +// Radio-idle deadline for the bounded stop drain at OTA start. +static constexpr uint32_t OTA_STOP_FLUSH_MS = 100; + +// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part. +constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; } // --------------------------------------------------------------------------- // Component lifecycle @@ -36,11 +44,20 @@ void BK72xxBLETracker::setup() { // Receive the controller's scan reports; the controller queues them from the // BLE task and delivers here on the main task. this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; unclaimed + // devices are logged only on one-shot scans (continuous would spam). + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); #ifdef USE_OTA_STATE_LISTENER // Pause scanning while an OTA update is in flight — on the single-core BK72xx the // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. ota::get_global_ota_callback()->add_global_state_listener(this); #endif + // scan_requested_ check: an on_boot start_scan latched before this setup() + // must keep the retry loop running (rp2/ln882h parity). + if (!this->scan_continuous_ && !this->scan_requested_) { + // Nothing to time until an explicit start_scan(); it re-enables the loop. + this->disable_loop(); + } } #ifdef USE_OTA_STATE_LISTENER @@ -50,30 +67,54 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, this->scan_continuous_before_ota_ = this->scan_continuous_; this->scan_requested_before_ota_ = this->scan_requested_; this->stop_scan(); + // The transfer starves the loop; a deferred stop would leave the radio + // scanning for the whole update, so drain it here, bounded. + if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) + ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update"); } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { // On success the device reboots, so restore only on a failed/aborted update; // loop() restarts the scan on its next iteration (continuous idle branch). if (this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() parked it } // A one-shot request that was still pending (latched, retrying) when the // OTA paused scanning is re-latched, not dropped — loop() resumes the retry. if (this->scan_requested_before_ota_) { this->scan_requested_before_ota_ = false; this->scan_requested_ = true; + this->enable_loop(); } } } #endif // USE_OTA_STATE_LISTENER void BK72xxBLETracker::loop() { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); + + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); + + // Before the drop branch: a drop after a stable run starts a fresh streak. + if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS) + this->failed_start_count_ = 0; + + // A terminal failure while we report running recovers via the normal retry + // path; the drop charges the backoff so a flapping controller escalates. + if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) { + ESP_LOGW(TAG, "Controller scan lost; retrying"); + this->scan_requested_ = true; + this->count_failed_start_(); + this->mark_scan_ended_(now); + } + if (this->scan_continuous_) { if (!this->scan_running_) { - // A start that succeeded re-anchored the period timer from a later millis(), - // so the stale `now` below would underflow the comparison and fire - // on_scan_end() for a scan that just began. Resume next iteration. + // One-iteration deferral; all stamps share this iteration's cached + // timestamp, so the period check below cannot underflow. if (this->try_start_with_backoff_(now)) return; } @@ -81,11 +122,7 @@ void BK72xxBLETracker::loop() { // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan // that never came up (start kept failing) does not fire spurious on_scan_end events. if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif + this->fire_scan_end_(); this->scan_period_start_ = now; } return; @@ -99,13 +136,14 @@ void BK72xxBLETracker::loop() { // would be silent: the scan never runs, stop_scan_() is never reached and // on_scan_end() never fires, leaving period-keyed consumers waiting forever. if (this->scan_requested_ && !this->scan_running_) { - // Same stale-`now` hazard as the continuous branch: start_scan_() stamps - // scan_start_time_ from a later millis(), so the duration check below would - // underflow and stop the scan in the iteration that started it. + // Same one-iteration deferral as the continuous branch. if (this->try_start_with_backoff_(now)) return; } if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + // A full-duration run proves the controller healthy even when duration is + // shorter than SCAN_STABLE_RESET_MS. + this->failed_start_count_ = 0; this->stop_scan_(); } } @@ -122,32 +160,54 @@ bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) { // even user-initiated attempts respect the backoff, so a start_scan() action // on a short cadence cannot hammer a failing controller; the attempt stays // inside the failure accounting below either way. - const uint8_t doublings = std::min(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS); - if ((!force || this->failed_start_count_ != 0) && - now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings)) + // Mid bring-up, observe instead of re-issuing (the hub self-advances). A + // SETTLED outcome completes immediately; only fresh attempts after FAILED + // are rate-limited. + const auto hub = this->parent_->last_scan_result(); + if (hub == bk72xx_ble::ScanOpResult::PENDING) return false; - this->last_scan_start_attempt_ = now; + if (hub == bk72xx_ble::ScanOpResult::FAILED) { + if (this->start_attempt_open_) { + // Our bring-up gave up asynchronously; charge it to the backoff. + this->start_attempt_open_ = false; + this->count_failed_start_(); + } + if ((!force || this->failed_start_count_ != 0) && + now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_)) + return false; + } this->start_scan_(); - if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + if (!this->scan_running_) { + if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) { + this->start_attempt_open_ = true; + return false; // the controller is still bringing the scan up; not a failure + } + this->count_failed_start_(); + } + return this->scan_running_; +} + +void BK72xxBLETracker::count_failed_start_() { + if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { ++this->failed_start_count_; if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); } } - return this->scan_running_; } void BK72xxBLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BK72xx BLE Tracker:\n" " Scan Duration: %" PRIu32 " s\n" - " Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" - " Scan Type: PASSIVE\n" + " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Type: %s (configured %s)\n" " Continuous Scanning: %s", - this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_, - this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_)); + this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_, + ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); } // --------------------------------------------------------------------------- @@ -156,31 +216,33 @@ void BK72xxBLETracker::dump_config() { // listener dispatch run in main-loop context with no cross-task handling here. // --------------------------------------------------------------------------- -void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { - // Raw callback (the raw-advertisement path). - if (this->raw_advertisement_callback_.is_set()) { - const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac), - .data = report.data, - .data_len = report.data_len, - .rssi = report.rssi, - .addr_type = report.addr_type}; - this->raw_advertisement_callback_.invoke(adv); - } +// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type, +// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and +// 5.2 fill it from gapm_ext_adv_report_ind.info). +static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - ble_device_base::ESPBTDevice device; - device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) { - found = true; - } +// Demux advertisements vs scan responses into the shared merger: the BDK +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { + const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK; + if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; } - // Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed - // it and the scan is one-shot (continuous scans would spam). - if (!found && !this->scan_continuous_) - this->discovered_log_.log_device(TAG, device); -#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); } // --------------------------------------------------------------------------- @@ -207,7 +269,8 @@ void BK72xxBLETracker::start_scan() { // against a failing controller, repeated start_scan() calls are rate-limited // like any other attempt. this->scan_requested_ = true; - this->try_start_with_backoff_(millis(), /* force= */ true); + this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_() + this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true); } void BK72xxBLETracker::restart_scan_duration() { @@ -218,7 +281,7 @@ void BK72xxBLETracker::restart_scan_duration() { // start_scan action fired more often than scan_duration_ would otherwise // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN // publish) rides on that period. - this->scan_start_time_ = millis(); + this->scan_start_time_ = App.get_loop_component_start_time(); } void BK72xxBLETracker::stop_scan() { @@ -231,24 +294,31 @@ void BK72xxBLETracker::stop_scan() { // Internal scan start / stop // --------------------------------------------------------------------------- +bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + void BK72xxBLETracker::start_scan_() { if (this->scan_running_) return; - if (!this->parent_->scan_start(static_cast(this->scan_interval_), - static_cast(this->scan_window_))) + if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->scan_running_ = true; this->scan_requested_ = false; // the latched one-shot request is satisfied - this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too + this->start_attempt_open_ = false; + // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()). this->scan_start_time_ = now; // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and // in non-continuous mode each period is an explicit start, so asymmetric logging // would read as the scanner failing to come back up. - ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f, - this->scan_interval_ * 0.625f); + ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)", + this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_), + ble_units_to_ms(this->scan_interval_)); // Re-anchor the on_scan_end period to every successful start — first start (so the // period counts from the scan, not from boot) and every restart after a stop (so // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous @@ -258,18 +328,48 @@ void BK72xxBLETracker::start_scan_() { this->scan_started_once_ = true; } +// Deliberate logical/physical split: on_scan_end() reports the tracker's +// intent while the hub winds the radio down asynchronously; OTA is the one +// path that must wait, and it flushes explicitly. void BK72xxBLETracker::stop_scan_() { - if (!this->scan_running_) - return; - this->parent_->scan_stop(); + this->start_attempt_open_ = false; // an abandoned bring-up is not charged + this->parent_->scan_stop(); // idempotent: releases whatever the hub holds + if (this->scan_running_) { + ESP_LOGD(TAG, "Scan stopped"); + this->mark_scan_ended_(App.get_loop_component_start_time()); + } + // Park when idle (the hub drives its own teardown); re-check because an + // on_scan_end automation may have restarted the scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_) + this->disable_loop(); +} + +// The period re-anchor keeps on_scan_end from double-firing in one iteration. +void BK72xxBLETracker::mark_scan_ended_(uint32_t now) { this->scan_running_ = false; - ESP_LOGD(TAG, "Scan stopped"); -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) -#endif - this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire + this->fire_scan_end_(); + this->scan_period_start_ = now; +} + +void BK72xxBLETracker::fire_scan_end_() { + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); +} + +// true = request latched, not applied: the reconciler applies it +// asynchronously and loop() recovers a failed re-arm (ln882h parity). +bool BK72xxBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // The controller reconciler restarts a running scan itself; the scan stays + // logically running. An idle scanner picks the mode up on its next start. + if (this->scan_running_) + this->controller_scan_start_(); + return true; } } // namespace esphome::bk72xx_ble_tracker diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index cc51918da5..59d17f9b84 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -21,6 +21,7 @@ // window: 30ms // duration: 5min // continuous: true +// active: true #pragma once @@ -29,6 +30,7 @@ #include "esphome/components/bk72xx_ble/bk72xx_ble.h" #include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -69,6 +71,12 @@ class BK72xxBLETracker : public Component, void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.active); runtime mode requests change + /// only the resolved mode. + void set_scan_active(bool scan_active) { + this->scan_active_ = scan_active; + this->scan_active_configured_ = scan_active; + } /// Set from YAML (scan_parameters.continuous); also the value /// configured_continuous() reports and a bare start_scan action restores. void set_configured_continuous(bool scan_continuous) { @@ -93,27 +101,19 @@ class BK72xxBLETracker : public Component, // ---- ble_device_base::BLEHub contract ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) { -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - this->listeners_.push_back(listener); -#endif + this->dispatcher_.register_listener(listener); } void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { - this->raw_advertisement_callback_ = callback; + this->dispatcher_.set_raw_advertisement_callback(callback); } static constexpr ble_device_base::HubCapabilities get_capabilities() { - // The Beken BDK exposes no active-scan path (passive scanning only), so the - // controller never solicits scan responses and never merges them; consumers - // relying on scan-response fields (device names) get them only where the - // receiver merges per address (Home Assistant does). No GATT client either. - // scan_mode_switch stays false for the same reason: with no active-scan - // path there is no mode to switch to. - return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; - } - bool request_scan_mode(bool active) { - // Passive-only controller: a passive request is already honored, an active - // one cannot be. - return !active; + // Active scanning is driven through bk72xx_ble's reconciler because the BDK + // API itself is passive-only. The controller delivers scan responses as + // separate reports; this tracker merges the pair before delivery (shared + // ScanResponseMerger, Bluedroid semantics). No GATT client. + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; } + bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. void get_adapter_mac(uint8_t out[6]) { @@ -123,7 +123,7 @@ class BK72xxBLETracker : public Component, out[i] = mac[5 - i]; } bool scan_running() { return this->scan_running_; } - bool scan_active() { return false; } // BK72xx scan is passive-only + bool scan_active() { return this->scan_active_; } // ---- bk72xx_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the @@ -133,15 +133,20 @@ class BK72xxBLETracker : public Component, protected: void start_scan_(); void stop_scan_(); - /// Attempt a rate-limited (re)start; returns true when the scan is running, - /// which means the caller must not compare its cached millis() against the - /// timestamps start_scan_() just refreshed. force bypasses the rate gate for - /// an explicit user start only while the failure streak is clean; a failing - /// controller rate-limits forced attempts too. Failure accounting always runs. + void fire_scan_end_(); + void mark_scan_ended_(uint32_t now); + /// Stamp-and-start for every controller scan attempt, so the retry rate + /// limit covers all callers. + bk72xx_ble::ScanOpResult controller_scan_start_(); + /// Rate-limited (re)start; true when the scan is running (the caller must + /// not reuse a `now` older than the stamps this refreshed). Force and + /// backoff rules are documented at the definition. bool try_start_with_backoff_(uint32_t now, bool force = false); + void count_failed_start_(); bool scan_running_{false}; - bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once // Defaults: the BK reference — 30 % duty cycle // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms @@ -149,30 +154,27 @@ class BK72xxBLETracker : public Component, uint32_t scan_duration_{300000}; bool scan_continuous_{true}; bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it + bool scan_active_{true}; // resolved mode; see scan_parameters.active + bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it #ifdef USE_OTA_STATE_LISTENER bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure #endif uint32_t scan_start_time_{0}; - uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries - uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success) - uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() + uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries + uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop()) + uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end() bool scan_started_once_{false}; // true after first successful scan start; gates the period timer - ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Parsed-advertisement consumers registered through ble_device_base. - // Codegen-sized: no heap allocation, no std::vector template instantiations. - StaticVector listeners_; -#endif - -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Per-period "Found device" DEBUG log with MAC dedup — shared implementation - // in ble_device_base, identical output on every tracker backend. Guarded like - // its only writer so a no-listener build does not carry an unused vector. - ble_device_base::DiscoveredDeviceLog discovered_log_{}; -#endif + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks). Merger clock: stash_adv() reads the PARENT's cached loop time + // (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this + // component's — same App.loop() pass, so the delta stays non-negative and + // the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; }; } // namespace esphome::bk72xx_ble_tracker diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index ae03003713..4da7d48882 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -247,24 +247,23 @@ def scan_parameters_schema( interval_default: str, *, window_default: str = "30ms", - supports_active: bool = False, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when - the tracker supports active scanning; it exposes the `active` option - (whose own default is on, esp32_ble_tracker behavior). + LN882H's SDK recommends 100/50 ms). The `active` option (default on) is + unconditional: active scanning is part of the tracker contract — every + current proxy client assumes it, so a passive-only tracker must not share + this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period, cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period, cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, } - if supports_active: - schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean return cv.All(cv.Schema(schema), validate_scan_parameters) diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 8e4c710bb1..9da6371012 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -83,9 +83,9 @@ struct HubCapabilities { /// Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: - /// a passive-only controller (bk72xx) can never switch, and a hub may - /// support active scanning yet still refuse the runtime switch - /// (esp32_ble_tracker drives its mode through its own tracker API). + /// a passive-only controller can never switch, and a hub may support + /// active scanning yet still refuse the runtime switch (esp32_ble_tracker + /// drives its mode through its own tracker API). bool scan_mode_switch; }; diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 8c218c0954..ee46f85a38 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -164,6 +164,7 @@ SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { "bluetooth_connection_hub.cpp": { PlatformFramework.RP2_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index b1c684fcc3..ffa942f27b 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -7,6 +7,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE, CONF_ID, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, @@ -47,15 +48,13 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Platforms with an in-tree ble_device_base BLE tracker hub whose controller -# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT -# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home -# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only -# proxy would be misdriven — bk72xx follows once the API carries a feature -# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). -# Coupled to bluetooth_connection: platforms here are also listed in its -# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES -# hub entry. -_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) +# supports active scanning — every current client (aioesphomeapi, bleak-esphome, +# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only +# hub must not be admitted (it would be misdriven). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and +# FILTER_SOURCE_FILES hub entry. +_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] @@ -264,11 +263,15 @@ def _validate_platform(config: ConfigType) -> ConfigType: # Fail here with the actual reason. Without this gate the error surfaces # later as an unresolvable hub ID ("Are you missing a hub declaration?") # on platforms where no hub component can be declared. + full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)]) + adv_only = ", ".join( + sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS)) + ) raise cv.Invalid( f"bluetooth_proxy is not supported on {CORE.target_platform}: no " "active-scan-capable BLE tracker hub is available for this " - "platform. It runs on esp32 and rp2 (full proxy) and the ln882x " - "family (advertisement-only)." + f"platform. It runs on {full} (full proxy) and {adv_only} " + "(advertisement-only)." ) if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 84f43fb54b..634b8c3bef 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -128,9 +128,7 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "320ms", supports_active=True -) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 8443799144..4bfaa93ab7 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -47,7 +47,7 @@ BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger # LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "100ms", window_default="50ms", supports_active=True + "100ms", window_default="50ms" ) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 99262babce..7709df9899 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -41,9 +41,7 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( # to_code(). `active` defaults on for esp32_ble_tracker parity; it adds scan # request TX and roughly doubles the reports through the queue, so # `active: false` is the lighter choice when scan response data is not needed. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( - "100ms", supports_active=True -) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") CONFIG_SCHEMA = cv.Schema( { diff --git a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml index 994855b782..123d4296db 100644 --- a/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml +++ b/tests/component_tests/bk72xx_ble_tracker/config/test_automations.yaml @@ -15,6 +15,7 @@ bk72xx: bk72xx_ble_tracker: scan_parameters: continuous: false + active: false on_ble_advertise: - mac_address: - AC:37:43:77:5F:4C diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index 3a03f98adf..777ae76b4f 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -48,6 +48,8 @@ def test_trigger_codegen( # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp + # active: false (non-default) flows through to the setter. + assert "->set_scan_active(false)" in main_cpp # Constructor call, not just the declaration: the parent argument is what # registers the trigger as a listener. assert re.search( diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index bd41a9476a..2549125a43 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -16,8 +16,8 @@ from esphome.components.ln882h_ble_tracker import ( from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA -def _validate(**kwargs: str) -> dict: - """Run a scan_parameters config through a passive tracker's real schema.""" +def _validate(**kwargs: str | bool) -> dict: + """Run a scan_parameters config through the bk72xx tracker's real schema.""" return BK72XX_SCHEMA(kwargs) @@ -48,11 +48,12 @@ def test_to_ble_units_truncates() -> None: def test_bk72xx_defaults_are_valid() -> None: - """bk72xx pins the BK reference rate: 100 ms interval, shared 30 ms window.""" + """bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window — + and exposes active (default on, like every active-capable tracker).""" config = _validate() assert to_ble_units(config["interval"]) == 160 assert to_ble_units(config["window"]) == 48 - assert "active" not in config + assert config["active"] is True def test_esp32_defaults_are_valid() -> None: @@ -86,10 +87,9 @@ def test_esp32_active_can_disable() -> None: assert config["active"] is False -def test_passive_schema_rejects_active_key() -> None: - """Trackers without active scan support must not silently accept the option.""" - with pytest.raises(cv.Invalid): - _validate(active="true") +def test_bk72xx_active_can_disable() -> None: + config = _validate(active=False) + assert config["active"] is False # --- accepted configurations --- diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index a47dfd53fa..16a3850d46 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -15,6 +15,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, @@ -27,18 +28,20 @@ from ..types import SetCoreConfigCallable # Advertisement-only hub platforms; rp2 runs the full proxy and has its own # tests below. HUB_PLATFORM_FRAMEWORKS = [ + PlatformFramework.BK72XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, ] HUB_TRACKERS = { + PLATFORM_BK72XX: "bk72xx_ble_tracker", PLATFORM_LN882X: "ln882h_ble_tracker", PLATFORM_RP2: "rp2_ble_tracker", } def test_hub_platform_list_covers_every_hub_platform() -> None: - # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise - # get no gate coverage at all; GATT platforms have their own tests. + # A platform added to _HUB_PLATFORMS would otherwise get no gate coverage + # at all; GATT platforms have their own tests. advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set( bluetooth_connection.HUB_MAX_CONNECTIONS ) diff --git a/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml new file mode 100644 index 0000000000..ad25fd6b64 --- /dev/null +++ b/tests/components/bk72xx_ble_tracker/validate-passive.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +# Passive scanning variant: the package merge keeps the shared parameters from +# common.yaml and overrides only the mode. +packages: + bk72xx_ble_tracker: !include common.yaml + +bk72xx_ble_tracker: + scan_parameters: + active: false diff --git a/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..331d679510 --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.bk72xx-ard.yaml @@ -0,0 +1,11 @@ +# Advertisement-only proxy on the bk72xx BLE hub (active-scan-capable since the +# tracker's packed-command start). Config-only: the CI base board generic-bk7252 +# is BLE 4.2 and cannot compile the BLE 5.x tracker. Same bare-hub arrangement +# as test.ln882x-ard.yaml: no explicit ble_hub_id so a grouped build cannot +# collide with bk72xx_ble_tracker's own fixture id. +packages: + common: !include common.yaml + +bk72xx_ble_tracker: + +bluetooth_proxy: From 2999e7b9257a668c9132d38f949369b181fd2132 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 10 Aug 2026 12:23:49 -0700 Subject: [PATCH 067/597] [modbus] Add API for reading/writing coils and discrete inputs in server mode (#17264) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 210 +++++++-- esphome/components/modbus/modbus.h | 53 ++- .../components/modbus/modbus_definitions.h | 13 + esphome/components/modbus/modbus_helpers.cpp | 11 +- esphome/components/modbus/modbus_helpers.h | 23 + .../modbus_controller/modbus_controller.cpp | 19 +- .../modbus_server/modbus_server.cpp | 9 +- tests/components/modbus/common.h | 11 + .../modbus/modbus_broadcast_test.cpp | 111 ++++- .../components/modbus/modbus_helpers_test.cpp | 14 + .../modbus/modbus_server_coils_test.cpp | 398 ++++++++++++++++++ .../command_payload_test.cpp | 31 ++ 12 files changed, 825 insertions(+), 78 deletions(-) create mode 100644 tests/components/modbus/modbus_server_coils_test.cpp create mode 100644 tests/components/modbus_controller/command_payload_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 9f2527d9fb..901bfcc52e 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1,4 +1,7 @@ #include "modbus.h" + +#include + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -378,10 +381,9 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { return nullptr; } -ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) { - if ((uint32_t) start_address + number_of_registers > 0x10000u) { - ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, - number_of_registers); +ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) { + if ((uint32_t) start_address + count > 0x10000u) { + ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count); return ExceptionCode::ILLEGAL_DATA_ADDRESS; } return std::nullopt; @@ -394,6 +396,11 @@ static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2; static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5; // FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1). static constexpr size_t READ_WRITE_VALUES_OFFSET = 9; +// A coil write (FC 0x0F) is function(1) + start(2) + quantity(2) + byte count(1) + packed bits. The largest +// one (MAX_NUM_OF_COILS_TO_WRITE coils) must fit the received request PDU, so the value subspan taken at +// WRITE_MULTIPLE_VALUES_OFFSET can never run past it. +static_assert(1 + WRITE_MULTIPLE_VALUES_OFFSET + packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE) <= MAX_PDU_SIZE, + "the largest FC 0x0F coil write must fit within MAX_PDU_SIZE"); ResponseStatus ModbusServerHub::parse_write_single_(std::span data, uint16_t &start_address, RegisterValues ®isters) { @@ -413,13 +420,59 @@ ResponseStatus ModbusServerHub::parse_write_multiple_(std::span d ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes); return ExceptionCode::ILLEGAL_DATA_VALUE; } - if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) { + if (ResponseStatus status = this->check_address_range_(start_address, number_of_registers); status.has_value()) { return status; } this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers); return std::nullopt; } +ResponseStatus ModbusServerHub::parse_read_request_(std::span data, uint16_t max_entities, + const LogString *entity_name, uint16_t &start_address, + uint16_t &count) { + // Every read request is start address(2) + quantity(2); only the protocol ceiling differs per function + // code, so registers and coils/discrete inputs validate through here and cannot drift apart. + start_address = helpers::get_data(data.data(), 0); + count = helpers::get_data(data.data(), 2); + if (count == 0 || count > max_entities) { + ESP_LOGW(TAG, "Invalid number of %s %" PRIu16, LOG_STR_ARG(entity_name), count); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + return this->check_address_range_(start_address, count); +} + +ResponseStatus ModbusServerHub::parse_write_single_coil_(std::span data, uint16_t &start_address, + bool &value) { + start_address = helpers::get_data(data.data(), 0); + const uint16_t raw_value = helpers::get_data(data.data(), WRITE_SINGLE_VALUES_OFFSET); + if (raw_value != 0xFF00 && raw_value != 0x0000) { + ESP_LOGW(TAG, "Invalid coil value 0x%04X", raw_value); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + // No range check needed: one coil can never push start_address + 1 past the address space. + value = raw_value == 0xFF00; + return std::nullopt; +} + +ResponseStatus ModbusServerHub::parse_write_multiple_coils_(std::span data, uint16_t &start_address, + uint16_t &count, std::span &packed_bytes) { + start_address = helpers::get_data(data.data(), 0); + const uint16_t number_of_bits = helpers::get_data(data.data(), 2); + const uint8_t number_of_bytes = helpers::get_data(data.data(), 4); + if (number_of_bits == 0 || number_of_bits > MAX_NUM_OF_COILS_TO_WRITE || + packed_bit_bytes(number_of_bits) != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of coils %" PRIu16 " or bytes %" PRIu8, number_of_bits, number_of_bytes); + return ExceptionCode::ILLEGAL_DATA_VALUE; + } + if (ResponseStatus status = this->check_address_range_(start_address, number_of_bits); status.has_value()) { + return status; + } + count = number_of_bits; + // coil values follow start(2) + quantity(2) + byte count(1) + packed_bytes = data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes); + return std::nullopt; +} + void ModbusServerHub::assemble_registers_(std::span values, RegisterValues ®isters) { for (size_t offset = 0; offset + 1 < values.size(); offset += 2) { registers.push_back(helpers::get_data(values.data(), offset)); @@ -427,11 +480,16 @@ void ModbusServerHub::assemble_registers_(std::span values, Regis } void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span data) { - // Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an - // unsupported function code or a validation failure is silently dropped instead of replying with an exception. - // Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet. + // Broadcasts are only meaningful for writes and are never answered (Modbus 4.1 / 6.12), so an unsupported + // function code or a validation failure is silently dropped instead of replying with an exception. Both + // register writes (FC 0x06/0x10) and coil writes (FC 0x05/0x0F) are broadcastable by spec, and each shares + // its parser with the addressed path so a broadcast is validated exactly as the unicast form would be. uint16_t start_address; RegisterValues registers; + uint16_t coil_count = 0; + std::span packed_bytes; + uint8_t single_bit = 0; // backs packed_bytes for a single-coil write, so it must outlive the loop below + bool coils = false; ResponseStatus status; switch (static_cast(function_code)) { case FunctionCode::WRITE_SINGLE_REGISTER: @@ -440,6 +498,19 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< case FunctionCode::WRITE_MULTIPLE_REGISTERS: status = this->parse_write_multiple_(data, start_address, registers); break; + case FunctionCode::WRITE_SINGLE_COIL: { + coils = true; + bool value = false; + status = this->parse_write_single_coil_(data, start_address, value); + single_bit = value ? 0x01 : 0x00; + coil_count = 1; + packed_bytes = std::span(&single_bit, 1); + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: + coils = true; + status = this->parse_write_multiple_coils_(data, start_address, coil_count, packed_bytes); + break; default: // Reads and read/write require a reply, so they are not valid as broadcasts. ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code); @@ -452,8 +523,12 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< // per-device outcome at V, and warn if the write reached nobody at all. bool accepted = false; for (auto *device : this->devices_) { - if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers); - device_status.has_value()) { + // Same handlers as an addressed write - a device cannot tell a broadcast apart, and does not need + // to: the hub owns the difference, which is only that no reply is ever sent. + const ResponseStatus device_status = + coils ? device->on_write_coils(start_address, PackedBits(packed_bytes, coil_count)) + : device->on_write_registers(start_address, registers); + if (device_status.has_value()) { ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(), static_cast(device_status.value())); } else { @@ -461,15 +536,19 @@ void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span< } } if (!accepted && !this->devices_.empty()) { + const uint16_t entity_count = coils ? coil_count : static_cast(registers.size()); + const LogString *const entity_name = coils ? LOG_STR("coils") : LOG_STR("registers"); // Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes // repeats forever, so warning per frame would flood the log. const uint32_t now = millis(); if (this->last_unaccepted_broadcast_warn_ == 0 || now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) { this->last_unaccepted_broadcast_warn_ = now; - ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); + ESP_LOGW(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count, + LOG_STR_ARG(entity_name), start_address); } else { - ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address); + ESP_LOGV(TAG, "No device accepted broadcast write of %" PRIu16 " %s at 0x%04X", entity_count, + LOG_STR_ARG(entity_name), start_address); } } } @@ -479,8 +558,7 @@ bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t fu std::span response_buffer, uint16_t &response_len) { // A handler that returns an exception leaves registers partially filled, so check the exception // first and forward it before validating the register count on the success path. - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return false; } @@ -535,17 +613,11 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func switch (static_cast(function_code)) { case FunctionCode::READ_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: { - // PDU data: start address(2) + quantity(2). - uint16_t start_address = helpers::get_data(data.data(), 0); - uint16_t number_of_registers = helpers::get_data(data.data(), 2); - if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); - this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - status = this->check_register_range_(start_address, number_of_registers); - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + uint16_t start_address; + uint16_t number_of_registers; + status = this->parse_read_request_(data, MAX_NUM_OF_REGISTERS_TO_READ, LOG_STR("registers"), start_address, + number_of_registers); + if (this->rejected_(address, function_code, status)) { return; } RegisterValues registers; @@ -571,8 +643,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func } else { status = this->parse_write_multiple_(data, start_address, registers); } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return; } status = device->on_write_registers(start_address, registers); @@ -580,6 +651,64 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func response_len = 4; break; } + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: { + uint16_t start_address; + uint16_t number_of_bits; + status = + this->parse_read_request_(data, MAX_NUM_OF_COILS_TO_READ, LOG_STR("bits"), start_address, number_of_bits); + if (this->rejected_(address, function_code, status)) { + return; + } + // Response: byte count(1) + packed bytes, written straight into the pre-zeroed response buffer. It + // always fits: the parse above caps the count, and a static_assert bounds that against MAX_RAW_SIZE. + const uint8_t byte_count = static_cast(packed_bit_bytes(number_of_bits)); + response_buffer[response_len++] = byte_count; + // Take the packed-bytes span off a span that knows response_buffer's real size, so a future non-zero + // response_len (e.g. a prefix written before the packed data) is a bounds error, not a silent overrun. + std::span packed_out = std::span(response_buffer).subspan(response_len, byte_count); + std::fill(packed_out.begin(), packed_out.end(), 0); + MutablePackedBits bits(packed_out, number_of_bits); + if (static_cast(function_code) == FunctionCode::READ_COILS) { + status = device->on_read_coils(start_address, bits); + } else { + status = device->on_read_discrete_inputs(start_address, bits); + } + if (this->rejected_(address, function_code, status)) { + return; + } + response_len += byte_count; + break; + } + case FunctionCode::WRITE_SINGLE_COIL: { + // A single coil is handed to the device as a one-bit packed view, the same form a multiple-coil + // write takes, so a device only ever implements one coil write handler. + uint16_t start_address; + bool value = false; + status = this->parse_write_single_coil_(data, start_address, value); + if (this->rejected_(address, function_code, status)) { + return; + } + const uint8_t single_bit = value ? 0x01 : 0x00; + status = device->on_write_coils(start_address, PackedBits(std::span(&single_bit, 1), 1)); + response_data = data.data(); // echo the request header per Modbus 6.5, 6.11 + response_len = 4; + break; + } + case FunctionCode::WRITE_MULTIPLE_COILS: { + // Parse and validate the coil write PDU into a packed-bit view; reply with an exception on failure. + uint16_t start_address; + uint16_t count; + std::span packed_bytes; + status = this->parse_write_multiple_coils_(data, start_address, count, packed_bytes); + if (this->rejected_(address, function_code, status)) { + return; + } + status = device->on_write_coils(start_address, PackedBits(packed_bytes, count)); + response_data = data.data(); // echo the request header per Modbus 6.5, 6.11 + response_len = 4; + break; + } case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) + // write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read. @@ -596,12 +725,11 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE); return; } - status = this->check_register_range_(read_start_address, number_of_registers); + status = this->check_address_range_(read_start_address, number_of_registers); if (!status.has_value()) { - status = this->check_register_range_(write_start_address, number_of_write_registers); + status = this->check_address_range_(write_start_address, number_of_write_registers); } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return; } // Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read @@ -614,8 +742,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func // from the values it just stored. status = device->on_write_registers(write_start_address, write_registers); } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); + if (this->rejected_(address, function_code, status)) { return; } RegisterValues registers; @@ -632,9 +759,7 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION); return; } - if (status.has_value()) { - this->send_exception_(address, function_code, status.value()); - } else { + if (!this->rejected_(address, function_code, status)) { this->send_response_(address, function_code, response_data, response_len); } } @@ -733,6 +858,19 @@ void ModbusServerHub::send_response_(uint8_t address, uint8_t function_code, con this->send_raw_(raw_frame, payload_len + 2); } +bool ModbusServerHub::rejected_(uint8_t address, uint8_t function_code, ResponseStatus status) { + if (!status.has_value()) + return false; + // The one place a rejection becomes an exception reply, so the log carries the transaction context a + // device handler never has: which client-facing address and function code drew which exception. DEBUG + // rather than WARN because an exception reply is a normal protocol outcome and arrives per frame - a + // probing or broken client would otherwise flood the log. The parse helpers still WARN with specifics. + ESP_LOGD(TAG, "Exception %" PRIu8 " replied to function 0x%02X for address %" PRIu8, + static_cast(status.value()), function_code, address); + this->send_exception_(address, function_code, status.value()); + return true; +} + void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code) { uint8_t raw_frame[3]; raw_frame[0] = address; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 3b6028e90a..6331f23f99 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -361,9 +361,27 @@ class ModbusServerHub : public Modbus { // Appends the big-endian register values in values to registers, in host byte order. void assemble_registers_(std::span values, RegisterValues ®isters); ModbusServerDevice *find_device_(uint8_t address); - // Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space, - // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required. - ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers); + // Returns std::nullopt if [start_address, start_address + count) fits in the 16-bit address space, + // otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required - a broadcast + // write is never answered, so the check cannot send it itself. Shared by the register and + // coil/discrete-input handlers, which all address the same 16-bit space. + ResponseStatus check_address_range_(uint16_t start_address, uint16_t count); + + // Parses a read request PDU (start address(2) + quantity(2)), shared by the register and + // coil/discrete-input reads so the two cannot drift apart. max_entities is the protocol ceiling for the + // function code; entity_name only labels the rejection log. + ResponseStatus parse_read_request_(std::span data, uint16_t max_entities, const LogString *entity_name, + uint16_t &start_address, uint16_t &count); + + // Parses a single-coil write PDU (FC 0x05), which carries a 2-byte on/off value rather than packed + // bytes. The caller packs value into a byte it owns to build the PackedBits view the handlers take. + ResponseStatus parse_write_single_coil_(std::span data, uint16_t &start_address, bool &value); + + // Parses a multiple-coil write PDU (FC 0x0F) into a packed-bit view pointing straight into the receive + // buffer, so the coil values are never copied. Both coil parsers are shared by the addressed and + // broadcast paths so the two validate identically. + ResponseStatus parse_write_multiple_coils_(std::span data, uint16_t &start_address, uint16_t &count, + std::span &packed_bytes); // Builds the body of a register read response (byte count followed by the big-endian register values) into // response_buffer. Shared by every function code that answers with register values, so the read reply stays @@ -374,6 +392,9 @@ class ModbusServerHub : public Modbus { uint16_t number_of_registers, const RegisterValues ®isters, std::span response_buffer, uint16_t &response_len); void send_raw_(const uint8_t *payload, uint16_t len); + // Sends and logs the exception reply when status holds one; returns true if the request was rejected. + // Every parse and handler rejection funnels through here, so the reply and its log cannot drift apart. + bool rejected_(uint8_t address, uint8_t function_code, ResponseStatus status); void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code); void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); uint8_t expecting_peer_response_{0}; @@ -644,18 +665,26 @@ class ModbusServerDevice { virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { return ExceptionCode::ILLEGAL_FUNCTION; }; - // Hub entry point for broadcast (address 0) writes, which are never answered. - ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues ®isters) { - this->broadcast_write_ = true; - ResponseStatus status = this->on_write_registers(start_address, registers); - this->broadcast_write_ = false; - return status; - } + /// Coil/discrete-input reads: set the requested bits (bit 0 = the coil at start_address) with + /// bits.set(). The view covers bits.size() pre-zeroed bits and writes land directly in the hub's + /// response buffer (no copy); it is only valid during the call. + virtual ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) { + return ExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) { + return this->on_read_bits(start_address, bits); + }; + virtual ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) { + return this->on_read_bits(start_address, bits); + }; + /// Coil writes deliver the values as a PackedBits view over the hub's receive buffer (only valid + /// during the call). A single-coil write (FC 0x05) arrives as bits.size() == 1. + virtual ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) { + return ExceptionCode::ILLEGAL_FUNCTION; + }; protected: uint8_t address_{0}; - // Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence. - bool broadcast_write_{false}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index 9ec776b67a..64f7210585 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -128,6 +128,19 @@ static_assert(MAX_RAW_SIZE + 2 == MAX_FRAME_SIZE, "a framed raw server payload m /// Bits pack 8 per data byte, rounded up to whole bytes. constexpr size_t packed_bit_bytes(size_t bits) { return (bits + 7) / 8; } +// A coil/discrete-input read answers with byte count(1) + packed_bit_bytes(count) bytes, which has to fit +// the raw frame body. The runtime check on that path catches a caller entering with bytes already written; +// this catches the other way in, raising the ceiling past what a frame can carry. +static_assert(1 + packed_bit_bytes(MAX_NUM_OF_COILS_TO_READ) <= MAX_RAW_SIZE, + "MAX_NUM_OF_COILS_TO_READ yields a read response larger than MAX_RAW_SIZE"); +static_assert(1 + packed_bit_bytes(MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) <= MAX_RAW_SIZE, + "MAX_NUM_OF_DISCRETE_INPUTS_TO_READ yields a read response larger than MAX_RAW_SIZE"); + +// The coil and discrete-input ceilings are separate limits in the spec but hold the same value, so the +// read paths validate both against MAX_NUM_OF_COILS_TO_READ. Should the spec ever split them, this fires. +static_assert(MAX_NUM_OF_COILS_TO_READ == MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, + "the coil and discrete-input read ceilings must match"); + /** Read-only view of Modbus-packed bits: bit 0 of byte 0 is the first bit (LSB first), the layout * coil/discrete-input values use on the wire. Bundles the bit count with the packed bytes so the * two cannot desynchronize. The view does not own the bytes - it is only valid while they are. diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index a0c8440c79..4287256101 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -69,8 +69,10 @@ uint16_t client_pdu_length(const uint8_t *frame, size_t size) { case FunctionCode::WRITE_SINGLE_REGISTER: return 5; // function(1) + output/register address(2) + value(2) case FunctionCode::WRITE_MULTIPLE_COILS: + // function(1) + start address(2) + quantity(2) + byte count(1) + packed coil data (8 coils per byte). + return 6 + (size > 5 ? std::min(frame[5], uint8_t(packed_bit_bytes(MAX_NUM_OF_COILS_TO_WRITE))) : 0); case FunctionCode::WRITE_MULTIPLE_REGISTERS: - // function(1) + start address(2) + quantity(2) + byte count(1) + data + // function(1) + start address(2) + quantity(2) + byte count(1) + register data (2 bytes per register). return 6 + (size > 5 ? std::min(frame[5], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. case FunctionCode::READ_FILE_RECORD: @@ -546,12 +548,7 @@ static PduBuffer create_write_coils_pdu_from_bools(uint16_t start_address, const return pdu; } CoilPackBuffer packed; - for (size_t i = 0; i != count; i++) { - if (i % 8 == 0) - packed.push_back(0); - if (values[i]) - packed[i / 8] |= (1 << (i % 8)); - } + pack_bits(packed, values); build_write_coils_pdu(pdu, start_address, PackedBits(std::span(packed.data(), packed.size()), count)); return pdu; } diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 2c312b8a61..e47a6835cd 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -257,6 +257,29 @@ inline bool bit_from_packed(int bit, std::span data) { ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } +/** Append packed bytes (LSB first) for the given bits onto a growable byte container. + * push_back-based so callers can build a payload incrementally (e.g. a std::vector + * with no fixed upper bound). A non-byte-aligned count appends n+1 bytes, the last holding + * the remaining bits in its low positions. + * @param out destination byte container exposing push_back(uint8_t) + * @param bits container of bool exposing range-based iteration + */ +template void pack_bits(Out &out, const Bits &bits) { + uint8_t byte = 0; + uint8_t bit = 0; + for (bool b : bits) { + if (b) + byte |= (1 << bit); + if (++bit == 8) { + out.push_back(byte); + byte = 0; + bit = 0; + } + } + if (bit != 0) // flush the final partial byte + out.push_back(byte); +} + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index da9d29887e..35f21fd0af 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -2,6 +2,8 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller"; @@ -427,14 +429,15 @@ ModbusCommandItem ModbusCommandItem::create_write_multiple_coils(ModbusControlle modbusdevice->on_write_register_response(register_type, start_address, data); }; - uint8_t *p = cmd.payload.init((values.size() + 7) / 8); - memset(p, 0, (values.size() + 7) / 8); - size_t bit = 0; - for (auto coil : values) { - if (coil) { - p[bit / 8] |= (1 << (bit % 8)); - } - bit++; + // Pack through the shared bit view (MutablePackedBits) so the coil wire layout lives in one place + // instead of an open-coded loop. + const size_t byte_count = modbus::packed_bit_bytes(values.size()); + uint8_t *p = cmd.payload.init(byte_count); + memset(p, 0, byte_count); + modbus::MutablePackedBits bits(std::span(p, byte_count), static_cast(values.size())); + for (size_t i = 0; i != values.size(); i++) { + if (values[i]) + bits.set(i, true); } return cmd; } diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index bf39efbd54..e63495cb25 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -145,12 +145,9 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, } return true; })) { - // On a broadcast every device that does not map these registers rejects them, which is the normal case. - if (this->broadcast_write_) { - ESP_LOGV(TAG, "Write request rejected before applying any register."); - } else { - ESP_LOGW(TAG, "Write request rejected before applying any register."); - } + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // registers this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "Write request rejected before applying any register."); return precheck; } diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index 659b72014c..d03ccf8ec3 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include "esphome/components/uart/uart_component.h" namespace esphome::modbus::testing { @@ -19,4 +20,14 @@ class NullUART : public uart::UARTComponent { void check_logger_conflict() override {} }; +// A UART that records every byte written so tests can assert on the exact wire response. +class RecordingUART : public NullUART { + public: + void write_array(const uint8_t *data, size_t len) override { + this->written.insert(this->written.end(), data, data + len); + } + + std::vector written; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_broadcast_test.cpp b/tests/components/modbus/modbus_broadcast_test.cpp index 5840259021..6f088f4888 100644 --- a/tests/components/modbus/modbus_broadcast_test.cpp +++ b/tests/components/modbus/modbus_broadcast_test.cpp @@ -28,6 +28,27 @@ class RecordingDevice : public ModbusServerDevice { std::vector last_values; }; +// A server device that records the coil writes the hub routes to it. Coils arrive as a PackedBits view +// over the hub's buffers, so the bits are copied out here rather than the view retained. +class RecordingCoilDevice : public ModbusServerDevice { + public: + explicit RecordingCoilDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override { + this->write_count++; + this->last_start_address = start_address; + this->last_bits.clear(); + for (uint16_t i = 0; i != bits.size(); i++) { + this->last_bits.push_back(bits[i]); + } + return std::nullopt; // return value is ignored for broadcasts, which are never answered + } + + int write_count{0}; + uint16_t last_start_address{0}; + std::vector last_bits; +}; + // A server device that rejects every write, to exercise the broadcast dispatch loop's rejection branch. class RejectingDevice : public ModbusServerDevice { public: @@ -41,15 +62,6 @@ class RejectingDevice : public ModbusServerDevice { int write_count{0}; }; -// A UART that records every byte written so the test can assert the hub sends no reply. -class RecordingUART : public testing::NullUART { - public: - void write_array(const uint8_t *data, size_t len) override { - this->written.insert(this->written.end(), data, data + len); - } - std::vector written; -}; - // Drives full frames through the server hub's receive path in tests. class TestServerHub : public ModbusServerHub { public: @@ -75,6 +87,8 @@ class TestServerHub : public ModbusServerHub { } // namespace +using testing::RecordingUART; + // A broadcast (address 0) single-register write reaches every registered device and is not answered. // Driven through the full receive parser (parse_modbus_frames) so the address-0 routing -- frame length, // CRC, and client-vs-broadcast dispatch -- is exercised, not just the handler below it. @@ -273,4 +287,83 @@ TEST(ModbusBroadcast, UnicastOutOfRangeWriteSendsSingleExceptionFrame) { EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); } +// A broadcast single-coil write (FC 0x05) reaches every device and is not answered. The 2-byte ON value +// is normalized to a one-bit view, so the handler sees the same shape as a multiple-coil write of one. +TEST(ModbusBroadcast, SingleCoilWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device_a(0x02); + RecordingCoilDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x05 payload: coil 0x00AC, value 0xFF00 (ON). + const uint8_t pdu_data[] = {0x00, 0xAC, 0xFF, 0x00}; + ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_data, sizeof(pdu_data))); + + for (RecordingCoilDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x00AC); + ASSERT_EQ(device->last_bits.size(), 1u); + EXPECT_TRUE(device->last_bits[0]); + } + EXPECT_TRUE(uart.written.empty()); // broadcasts are never answered +} + +// A broadcast multiple-coil write (FC 0x0F) delivers the packed bits to every device, LSB first. +TEST(ModbusBroadcast, MultipleCoilWriteReachesAllDevicesWithoutReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device_a(0x02); + RecordingCoilDevice device_b(0x03); + hub.register_device(&device_a); + hub.register_device(&device_b); + + // FC 0x0F payload: start 0x0013, 10 coils, 2 bytes, 0xCD 0x01 -> bit 0 set, bit 8 set. + const uint8_t pdu_data[] = {0x00, 0x13, 0x00, 0x0A, 0x02, 0xCD, 0x01}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), pdu_data, sizeof(pdu_data))); + + for (RecordingCoilDevice *device : {&device_a, &device_b}) { + EXPECT_EQ(device->write_count, 1); + EXPECT_EQ(device->last_start_address, 0x0013); + ASSERT_EQ(device->last_bits.size(), 10u); + EXPECT_TRUE(device->last_bits[0]); // 0xCD bit 0 + EXPECT_FALSE(device->last_bits[1]); // 0xCD bit 1 + EXPECT_TRUE(device->last_bits[8]); // 0x01 bit 0 + EXPECT_FALSE(device->last_bits[9]); // padding bit + } + EXPECT_TRUE(uart.written.empty()); +} + +// A coil broadcast that fails validation is dropped exactly like a bad register broadcast: no handler +// call and, because broadcasts are never answered, no exception frame either. +TEST(ModbusBroadcast, InvalidCoilBroadcastProducesNoWriteAndNoReply) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + RecordingCoilDevice device(0x02); + hub.register_device(&device); + + // Byte count disagrees with the coil quantity: 10 coils need 2 bytes, not 1. + const uint8_t bad_count[] = {0x00, 0x13, 0x00, 0x0A, 0x01, 0xCD}; + ASSERT_TRUE(hub.run_receive_parser_for_test( + BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), bad_count, sizeof(bad_count))); + EXPECT_EQ(device.write_count, 0); + + // A single-coil value must be 0x0000 or 0xFF00; anything else is out of spec. + const uint8_t bad_value[] = {0x00, 0xAC, 0x12, 0x34}; + ASSERT_TRUE(hub.run_receive_parser_for_test(BROADCAST_ADDRESS, static_cast(FunctionCode::WRITE_SINGLE_COIL), + bad_value, sizeof(bad_value))); + EXPECT_EQ(device.write_count, 0); + + EXPECT_TRUE(uart.written.empty()); +} + } // namespace esphome::modbus diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 768c23c33c..6a65c3bf68 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -426,6 +426,20 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } +// --- packed bit helpers ------------------------------------------------------ + +TEST(ModbusHelpersTest, PackBitsAppendsToContainer) { + // Bits are packed LSB first: the first value is bit 0 of the first byte, and the push_back + // overload appends packed bytes onto a growable container preserving existing content. + std::vector bits{true, false, true, true, false, false, false, false, true, true}; + std::vector out{0x55}; // pre-existing content must be preserved + pack_bits(out, bits); + ASSERT_EQ(out.size(), 3u); // leading byte + 2 packed bytes (10 bits) + EXPECT_EQ(out[0], 0x55); + EXPECT_EQ(out[1], 0x0D); // 0b00001101 + EXPECT_EQ(out[2], 0x03); // bits 8 and 9 -> bits 0,1 of second byte +} + // --- typed builders ---------------------------------------------------------- TEST(ModbusTypedBuilders, ReadPduWireBytes) { diff --git a/tests/components/modbus/modbus_server_coils_test.cpp b/tests/components/modbus/modbus_server_coils_test.cpp new file mode 100644 index 0000000000..e4bf3f6b14 --- /dev/null +++ b/tests/components/modbus/modbus_server_coils_test.cpp @@ -0,0 +1,398 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/hal.h" + +namespace esphome::modbus { + +namespace { + +// A server device backed by a small coil array: reads deliver the stored bits, writes apply them. +class CoilDevice : public ModbusServerDevice { + public: + explicit CoilDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override { + this->read_count++; + for (uint16_t i = 0; i < bits.size(); i++) + bits.set(i, this->coils[start_address + i]); + return std::nullopt; + } + + ResponseStatus on_write_coils(uint16_t start_address, PackedBits bits) override { + this->write_count++; + this->last_write_count = bits.size(); + for (uint16_t i = 0; i < bits.size(); i++) + this->coils[start_address + i] = bits[i]; + return std::nullopt; + } + + bool coils[32] = {}; + int read_count{0}; + int write_count{0}; + uint16_t last_write_count{0}; +}; + +// A device with no bit handlers, to exercise the ILLEGAL_FUNCTION defaults. +class NoBitsDevice : public ModbusServerDevice { + public: + explicit NoBitsDevice(uint8_t address) { this->set_address(address); } +}; + +// Distinguishes the two bit-read entry points: each fills a different pattern and counts its calls, so a +// test can prove FC 0x01 vs 0x02 dispatch routes to the right handler (and not merely that bits came back). +class DualReadDevice : public ModbusServerDevice { + public: + explicit DualReadDevice(uint8_t address) { this->set_address(address); } + + ResponseStatus on_read_coils(uint16_t start_address, MutablePackedBits bits) override { + this->coil_reads++; + bits.set(0, true); // pattern 0x01 + return std::nullopt; + } + ResponseStatus on_read_discrete_inputs(uint16_t start_address, MutablePackedBits bits) override { + this->discrete_reads++; + bits.set(1, true); // pattern 0x02 + return std::nullopt; + } + + int coil_reads{0}; + int discrete_reads{0}; +}; + +// Overrides only on_read_bits() - the shared fallback the header documents that on_read_coils() and +// on_read_discrete_inputs() default to. Both FC 0x01 and FC 0x02 must reach it. +class BitsOnlyDevice : public ModbusServerDevice { + public: + explicit BitsOnlyDevice(uint8_t address) { this->set_address(address); } + ResponseStatus on_read_bits(uint16_t start_address, MutablePackedBits bits) override { + this->calls++; + bits.set(0, true); // set bit 0 so the response proves the fallback ran + return std::nullopt; + } + int calls{0}; +}; + +using testing::RecordingUART; + +// Exposes the client-frame parser so a fully CRC-framed request can be pushed through the hub. +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + void prime_send_timestamps_for_test() { + uint32_t now = millis(); + this->last_modbus_byte_ = now; + this->last_send_ = now; + } + + bool process_full_client_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *pdu_data, + size_t pdu_data_len) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(pdu_data_len + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), pdu_data, pdu_data + pdu_data_len); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + return this->parse_modbus_client_frame_(); + } +}; + +struct CoilFixture { + CoilFixture() { + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + hub.register_device(&device); + } + TestServerHub hub; + RecordingUART uart; + CoilDevice device{0x02}; +}; + +} // namespace + +// A coil read returns byte count + packed bits, set by the handler directly in the response buffer. +TEST(ModbusServerCoils, ReadCoilsReturnsPackedBits) { + CoilFixture f; + f.device.coils[0] = true; + f.device.coils[2] = true; + f.device.coils[3] = true; + f.device.coils[9] = true; + + // FC 0x01: start 0x0000, quantity 10 -> 2 packed bytes + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 1); + // Response: address(1) + fc(1) + byte count(1) + packed(2) + CRC(2) + ASSERT_EQ(f.uart.written.size(), 7u); + EXPECT_EQ(f.uart.written[0], 0x02); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS)); + EXPECT_EQ(f.uart.written[2], 2u); // byte count + EXPECT_EQ(f.uart.written[3], 0x0D); // coils 0,2,3 + EXPECT_EQ(f.uart.written[4], 0x02); // coil 9 -> bit 1 of byte 1 +} + +// A device overriding only on_read_bits() - the documented fallback - still serves both FC 0x01 (coils) +// and FC 0x02 (discrete inputs), since on_read_coils()/on_read_discrete_inputs() default to it. +TEST(ModbusServerCoils, ReadBitsFallbackServesBothCoilsAndDiscreteInputs) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + BitsOnlyDevice device{0x05}; + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x01}; // start 0x0000, quantity 1 + + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.calls, 1); + // address(1) + fc(1) + byte count(1) + packed(1) + CRC(2); bit 0 set -> 0x01 + ASSERT_EQ(uart.written.size(), 6u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_COILS)); + EXPECT_EQ(uart.written[3], 0x01); + + uart.written.clear(); + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x05, static_cast(FunctionCode::READ_DISCRETE_INPUTS), + pdu_data, sizeof(pdu_data))); + EXPECT_EQ(device.calls, 2); + ASSERT_EQ(uart.written.size(), 6u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_DISCRETE_INPUTS)); + EXPECT_EQ(uart.written[3], 0x01); +} + +// A multiple-coil write hands the handler the packed wire bytes and echoes the request header. +TEST(ModbusServerCoils, WriteMultipleCoilsAppliesPackedBits) { + CoilFixture f; + + // FC 0x0F: start 0x0000, quantity 10, byte count 2, packed values 0x0D 0x02 + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x02, 0x0D, 0x02}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(f.device.write_count, 1); + EXPECT_EQ(f.device.last_write_count, 10u); + EXPECT_TRUE(f.device.coils[0]); + EXPECT_FALSE(f.device.coils[1]); + EXPECT_TRUE(f.device.coils[2]); + EXPECT_TRUE(f.device.coils[3]); + EXPECT_TRUE(f.device.coils[9]); + EXPECT_FALSE(f.device.coils[10]); + // Response echoes start address + quantity: address(1) + fc(1) + start(2) + quantity(2) + CRC(2) + ASSERT_EQ(f.uart.written.size(), 8u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS)); +} + +// A single-coil write (FC 0x05) is normalized to a one-bit packed buffer. +TEST(ModbusServerCoils, WriteSingleCoilNormalizedToOneBit) { + CoilFixture f; + + const uint8_t pdu_on[] = {0x00, 0x03, 0xFF, 0x00}; // coil 3 ON + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_on, sizeof(pdu_on))); + EXPECT_EQ(f.device.last_write_count, 1u); + EXPECT_TRUE(f.device.coils[3]); + + f.uart.written.clear(); + f.hub.prime_send_timestamps_for_test(); + const uint8_t pdu_off[] = {0x00, 0x03, 0x00, 0x00}; // coil 3 OFF + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_off, sizeof(pdu_off))); + EXPECT_FALSE(f.device.coils[3]); + EXPECT_EQ(f.device.write_count, 2); +} + +// An invalid single-coil value (not 0xFF00/0x0000) is rejected with ILLEGAL_DATA_VALUE, no write. +TEST(ModbusServerCoils, InvalidSingleCoilValueRejected) { + CoilFixture f; + + const uint8_t pdu_data[] = {0x00, 0x03, 0x12, 0x34}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(f.device.write_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_SINGLE_COIL) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// Read quantity validation lives in the shared read-request parser, so the register and bit reads cannot +// drift apart. These pin both ends of the range for coils; the register case below pins that the same +// parser is on that path too. +TEST(ModbusServerCoils, ZeroCoilReadQuantityRejected) { + CoilFixture f; + + // FC 0x01: start 0x0000, quantity 0 - a read of nothing is out of spec. + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); // one exception frame + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +TEST(ModbusServerCoils, OverLimitCoilReadQuantityRejected) { + CoilFixture f; + + // One past MAX_NUM_OF_COILS_TO_READ (2000 = 0x07D0), which no frame could carry anyway. + const uint8_t pdu_data[] = {0x00, 0x00, 0x07, 0xD1}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + EXPECT_EQ(f.device.read_count, 0); + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// The register read path shares that parser, so a zero quantity is rejected there identically. Lives +// beside the coil cases deliberately: together they are what stops the shared parser being bypassed on +// one side without the other noticing. +TEST(ModbusServerCoils, ZeroRegisterReadQuantityRejectedByTheSameParser) { + CoilFixture f; + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x00}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_HOLDING_REGISTERS), + pdu_data, sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_HOLDING_REGISTERS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); +} + +// A device without bit handlers rejects coil requests with ILLEGAL_FUNCTION via the defaults. +TEST(ModbusServerCoils, UnhandledCoilReadIsIllegalFunction) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + NoBitsDevice device(0x02); + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); +} + +// The view contracts are enforced, not merely documented: bytes() returns exactly ceil(size()/8) bytes +// even over a larger buffer (forwarding it can never leak trailing buffer content), and set() drops +// out-of-range bits instead of writing past the span (on the server read path that span wraps a stack +// response buffer). +TEST(ModbusServerCoils, PackedBitsViewContractsEnforced) { + uint8_t buf[8] = {}; + PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer + EXPECT_EQ(view.bytes().size(), 2u); + + PackedBits short_view(std::span(buf, 1), 10); // contract-violating: 10 bits over 1 byte + EXPECT_EQ(short_view.bytes().size(), 1u); // clamped to the real span, not a fabricated 2-byte span + + MutablePackedBits bits(std::span(buf, 2), 10); + bits.set(9, true); // in range: lands in byte 1 + bits.set(10, true); // out of range: dropped + bits.set(300, true); // far out of range: dropped, no write past the span + EXPECT_EQ(buf[1], 0x02); + for (size_t i = 2; i < sizeof(buf); i++) + EXPECT_EQ(buf[i], 0) << "byte " << i; +} + +// FC 0x02 must dispatch to on_read_discrete_inputs, not on_read_coils: the two handlers fill different +// patterns, so a swapped dispatch would fail on both the counters and the wire bytes. +TEST(ModbusServerCoils, ReadDiscreteInputsDispatchesToItsOwnHandler) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + DualReadDevice device(0x02); + hub.register_device(&device); + + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x08}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_DISCRETE_INPUTS), + pdu_data, sizeof(pdu_data))); + + EXPECT_EQ(device.discrete_reads, 1); + EXPECT_EQ(device.coil_reads, 0); + ASSERT_GE(uart.written.size(), 4u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::READ_DISCRETE_INPUTS)); + EXPECT_EQ(uart.written[3], 0x02); // the discrete handler's pattern, not the coil handler's + + uart.written.clear(); + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + EXPECT_EQ(device.coil_reads, 1); + EXPECT_EQ(device.discrete_reads, 1); + ASSERT_GE(uart.written.size(), 4u); + EXPECT_EQ(uart.written[3], 0x01); +} + +// The write-side ILLEGAL_FUNCTION defaults: a device without bit handlers rejects coil writes too +// (single and multiple), mirroring the read-side default already covered above. +TEST(ModbusServerCoils, UnhandledCoilWriteIsIllegalFunction) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + hub.prime_send_timestamps_for_test(); + NoBitsDevice device(0x02); + hub.register_device(&device); + + const uint8_t single[] = {0x00, 0x03, 0xFF, 0x00}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_SINGLE_COIL), + single, sizeof(single))); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_SINGLE_COIL) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); + + uart.written.clear(); + const uint8_t multiple[] = {0x00, 0x00, 0x00, 0x08, 0x01, 0xAA}; + ASSERT_TRUE(hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + multiple, sizeof(multiple))); + ASSERT_EQ(uart.written.size(), 5u); + EXPECT_EQ(uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80); + EXPECT_EQ(uart.written[2], static_cast(ExceptionCode::ILLEGAL_FUNCTION)); +} + +// FC 0x0F with a byte count that does not match ceil(quantity / 8) is ILLEGAL_DATA_VALUE and never +// reaches the handler. +TEST(ModbusServerCoils, WriteCoilsByteCountMismatchRejected) { + CoilFixture f; + + // quantity 10 needs 2 bytes; claim 1 + const uint8_t pdu_data[] = {0x00, 0x00, 0x00, 0x0A, 0x01, 0xFF}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::WRITE_MULTIPLE_COILS), + pdu_data, sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::WRITE_MULTIPLE_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_VALUE)); + EXPECT_EQ(f.device.write_count, 0); +} + +// A coil range that runs past address 0xFFFF is ILLEGAL_DATA_ADDRESS and never reaches the handler. +TEST(ModbusServerCoils, CoilAddressRangeOverflowRejected) { + CoilFixture f; + + // start 0xFFF8, quantity 16 -> 0x10008 > 0x10000 + const uint8_t pdu_data[] = {0xFF, 0xF8, 0x00, 0x10}; + ASSERT_TRUE(f.hub.process_full_client_frame_for_test(0x02, static_cast(FunctionCode::READ_COILS), pdu_data, + sizeof(pdu_data))); + + ASSERT_EQ(f.uart.written.size(), 5u); + EXPECT_EQ(f.uart.written[1], static_cast(FunctionCode::READ_COILS) | 0x80); + EXPECT_EQ(f.uart.written[2], static_cast(ExceptionCode::ILLEGAL_DATA_ADDRESS)); + EXPECT_EQ(f.device.read_count, 0); +} + +} // namespace esphome::modbus diff --git a/tests/components/modbus_controller/command_payload_test.cpp b/tests/components/modbus_controller/command_payload_test.cpp new file mode 100644 index 0000000000..c125a44da5 --- /dev/null +++ b/tests/components/modbus_controller/command_payload_test.cpp @@ -0,0 +1,31 @@ +#include + +#include +#include + +#include "esphome/components/modbus_controller/modbus_controller.h" + +namespace esphome::modbus_controller::testing { + +// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum +// because a fixed pack buffer sized for the maximum would silently truncate there while the quantity +// field still claimed every coil - and the truncated frame would fit the RTU limit and go on the wire +// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with +// a log instead. +TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) { + ModbusController controller; + std::vector coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true); + auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); + EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size())); +} + +// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce. +TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) { + ModbusController controller; + const std::vector coils{true, false, true, true}; + auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); + ASSERT_EQ(cmd.payload.size(), 1u); + EXPECT_EQ(cmd.payload.data()[0], 0b00001101); +} + +} // namespace esphome::modbus_controller::testing From 8650d175f4272bfb85a098f7e5173ac0debe2b18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:26:41 -0500 Subject: [PATCH 068/597] Bump aiohappyeyeballs from 2.6.2 to 2.7.1 (#18244) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4501b733a6..cd6ebc7db7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ platformio==6.1.19 esptool==5.3.1 click==8.3.3 aioesphomeapi==45.7.0 -aiohappyeyeballs==2.6.2 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi +aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import From b5a78c6c468d33becd9a6656968f920df50c9d51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:36:22 +0000 Subject: [PATCH 069/597] Bump CodSpeedHQ/action from 5.0.2 to 5.0.3 (#18246) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735ba73c99..7e695bb46b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,7 +466,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2 + uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: run: | . venv/bin/activate From e7a2980b9cf1a0121258693b0e5b13f9e125cfd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:44:30 -0400 Subject: [PATCH 070/597] Bump ruff from 0.16.1 to 0.16.2 (#18245) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b5753066ba..0905fe6be1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.1 # also change in .pre-commit-config.yaml when updating +ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating prek==0.4.12 # also change in .github/workflows/ci.yml when updating From 334afdefb589563493937f7332ff216e1ab48994 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:44:32 -0400 Subject: [PATCH 071/597] Update argcomplete requirement from >=3.7.0 to >=3.7.2 (#18243) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cd6ebc7db7..98c2f47d7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,4 +34,4 @@ filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache) pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.7.0 +argcomplete>=3.7.2 From 8a16ead8ce7286aa3690950cf8d63b6818d24995 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 14:49:14 -0500 Subject: [PATCH 072/597] [web_server] Fix basic auth with long credentials (#18237) --- esphome/components/web_server/__init__.py | 18 +++++++-- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 34 ++++++++++++---- .../web_server/test_web_server_auth.py | 39 ++++++++++++++++++- .../web_server_auth_basic_esp8266.yaml | 16 ++++++++ .../web_server_auth_digest_esp8266.yaml | 16 ++++++++ .../test-basicauth.esp8266-ard.yaml | 8 ++++ .../web_server/test.rp2040-ard.yaml | 2 +- 8 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml create mode 100644 tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml create mode 100644 tests/components/web_server/test-basicauth.esp8266-ard.yaml diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2587d13b9e..c1887cc3fc 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import gzip import logging import re @@ -406,10 +407,21 @@ async def to_code(config): # The scheme is fixed at build time so the unused Basic/Digest code path is compiled # out. Basic is the current default (the absence of this define); an explicit # 'type: digest' opts in early. Default changes to digest in 2027.1.0. - if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + is_digest = auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST + if is_digest: cg.add_define("USE_WEBSERVER_AUTH_DIGEST") - cg.add(paren.set_auth_username(auth[CONF_USERNAME])) - cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + if is_digest or CORE.is_esp32: + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + else: + # Every non-ESP32 basic auth build takes this path. The ESP8266 and RP2040 + # core base64 encoders wrap output every 72 chars, which breaks + # ESPAsyncWebServer's basic auth compare for long credentials. + # Precompute the hash here and let C++ compare the raw header payload. + basic_hash = base64.b64encode( + f"{auth[CONF_USERNAME]}:{auth[CONF_PASSWORD]}".encode() + ).decode() + cg.add(paren.set_auth_basic_hash(basic_hash)) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index ccfc04f674..873c5b5a49 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -7,7 +7,7 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { #ifdef USE_WEBSERVER_AUTH - if (!credentials_.username.empty()) { + if (credentials_.is_set()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 9657853a73..c647a13b50 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) -#include #include #include "esphome/core/progmem.h" @@ -46,9 +45,20 @@ class MiddlewareHandler : public AsyncWebHandler { }; #ifdef USE_WEBSERVER_AUTH +// All fields point to string literals in generated code; nothing is copied. struct Credentials { - std::string username; - std::string password; +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + const char *username{nullptr}; + const char *password{nullptr}; + bool is_set() const { return username != nullptr; } +#else + // base64("username:password"), precomputed at codegen time. Used by every non-ESP32 basic + // auth build. The ESP8266 and RP2040 core libb64 wraps base64 output every 72 chars, so + // letting the library encode and compare fails for long credentials; instead the header + // payload is compared against this hash. + const char *basic_auth_hash{nullptr}; + bool is_set() const { return basic_auth_hash != nullptr; } +#endif }; class AuthMiddlewareHandler : public MiddlewareHandler { @@ -57,10 +67,14 @@ class AuthMiddlewareHandler : public MiddlewareHandler { : MiddlewareHandler(next), credentials_(credentials) {} bool check_auth(AsyncWebServerRequest *request) { - bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + bool success = request->authenticate(credentials_->username, credentials_->password); +#else + bool success = request->authenticate(credentials_->basic_auth_hash); +#endif if (!success) { - // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is - // compiled out. On ESP32 our own server picks the scheme internally. #if USE_ESP32 request->requestAuthentication(); #elif defined(USE_WEBSERVER_AUTH_DIGEST) @@ -125,8 +139,12 @@ class WebServerBase final { AsyncWebServer *get_server() const { return this->server_; } #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); } +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + void set_auth_username(const char *auth_username) { credentials_.username = auth_username; } + void set_auth_password(const char *auth_password) { credentials_.password = auth_password; } +#else + void set_auth_basic_hash(const char *hash) { credentials_.basic_auth_hash = hash; } +#endif #endif void add_handler(AsyncWebHandler *handler); diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py index 82635b26da..183c586a36 100644 --- a/tests/component_tests/web_server/test_web_server_auth.py +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, ) -> None: - """Auth type basic builds Basic and does not warn.""" - generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + """Auth type basic on ESP32 uses plaintext credentials and does not warn.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic.yaml" + ) + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp assert _has_define("USE_WEBSERVER_AUTH") assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") assert _DEFAULT_CHANGE_WARNING not in caplog.text +def test_web_server_auth_basic_esp8266_uses_precomputed_hash( + generate_main: Callable[[str], str], +) -> None: + """Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml" + ) + + assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp + assert "set_auth_username" not in main_cpp + assert "set_auth_password" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + + +def test_web_server_auth_digest_esp8266_uses_plaintext_credentials( + generate_main: Callable[[str], str], +) -> None: + """Auth type digest on ESP8266 uses plaintext credentials, not the basic hash.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + + def test_web_server_auth_explicit_digest( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, diff --git a/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml new file mode 100644 index 0000000000..79e0c0ccf5 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml new file mode 100644 index 0000000000..59565f8733 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test-basicauth.esp8266-ard.yaml b/tests/components/web_server/test-basicauth.esp8266-ard.yaml new file mode 100644 index 0000000000..6a01180892 --- /dev/null +++ b/tests/components/web_server/test-basicauth.esp8266-ard.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + type: basic diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index e4d50d7776..6a01180892 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -4,5 +4,5 @@ packages: web_server: auth: username: admin - password: password + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA type: basic From 596827c51cec6a0ab6833c19544074f36caa6a3c Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 10 Aug 2026 13:18:48 -0700 Subject: [PATCH 073/597] [modbus_client] Add read/write multiple registers (FC 0x17) (#18215) --- esphome/components/modbus/__init__.py | 1 + esphome/components/modbus/modbus.cpp | 23 +++- esphome/components/modbus/modbus.h | 16 ++- esphome/components/modbus/modbus_helpers.cpp | 89 +++++++++----- esphome/components/modbus/modbus_helpers.h | 39 +++++- esphome/components/modbus_client/__init__.py | 100 ++++++++++++++-- .../components/modbus_client/modbus_client.h | 52 ++++++++ .../modbus/modbus_client_device_test.cpp | 29 +++++ .../components/modbus/modbus_helpers_test.cpp | 65 ++++++++++ tests/components/modbus_client/common.yaml | 12 ++ .../uart_mock_modbus_client_read_write.yaml | 111 ++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 35 ++++++ 12 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 377dadad76..58bd0f65dc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -28,6 +28,7 @@ MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 MAX_NUM_OF_COILS_TO_WRITE = 1968 MAX_NUM_OF_REGISTERS_TO_READ = 125 MAX_NUM_OF_REGISTERS_TO_WRITE = 123 +MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121 modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 901bfcc52e..cff086aeea 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -382,7 +382,7 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { } ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) { - if ((uint32_t) start_address + count > 0x10000u) { + if (!helpers::address_range_fits(start_address, count)) { ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count); return ExceptionCode::ILLEGAL_DATA_ADDRESS; } @@ -1056,7 +1056,8 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M continue; if (device == nullptr) { // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). - const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]); + const bool requeueable = + !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]); if (requeueable) { ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); } else { @@ -1236,7 +1237,14 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu switch (function_code) { case FunctionCode::READ_HOLDING_REGISTERS: - case FunctionCode::READ_INPUT_REGISTERS: { + case FunctionCode::READ_INPUT_REGISTERS: + // FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a + // plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its + // response carries only that read data, and the write half is confirmed by the response arriving at all. + // An exception routes here as well (the gate only validates the request when status is set), delivering + // empty registers with the error in status - so a 0x17 subclass handles success and failure in the one + // on_read_holding_registers() callback and never needs to also override on_error(). + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // Decode the big-endian register words into host byte order. The gate guarantees a success response // carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On @@ -1248,10 +1256,15 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu } } std::span register_span(registers.data(), registers.size()); - if (function_code == FunctionCode::READ_HOLDING_REGISTERS) { + if (function_code == FunctionCode::READ_INPUT_REGISTERS) { + this->on_read_input_registers(start_address, register_span, status); + } else if (function_code == FunctionCode::READ_HOLDING_REGISTERS || + function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { this->on_read_holding_registers(start_address, register_span, status); } else { - this->on_read_input_registers(start_address, register_span, status); + // Unreachable for the current case labels; match explicitly so a function code added to this group + // later is diverted to on_custom_response() rather than silently delivered as a holding read. + this->on_custom_response(request_pdu, response_pdu, status); } break; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 6331f23f99..6bd407a687 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -151,9 +151,7 @@ struct ModbusDeviceCommand { static CommandPriority classify(uint8_t function_code) { if (helpers::is_function_code_exception(function_code)) return CommandPriority::READ; - const auto code = static_cast(function_code); - if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER || - code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { + if (helpers::is_function_code_write(function_code)) { return CommandPriority::WRITE; } return CommandPriority::READ; @@ -162,7 +160,7 @@ struct ModbusDeviceCommand { // Requests this entry can serve: a standard read twice (run plus one re-run), everything else once. uint8_t max_pending() const { const uint8_t fc = this->frame.pdu()[0]; - const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc); + const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc); return (requeueable && !this->continuous) ? 2 : 1; } // Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for @@ -594,6 +592,16 @@ class ModbusClientDevice { bool write_multiple_coils(uint16_t start_address, PackedBits bits) { return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } + /// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the + /// read registers, the same wire shape as a holding-register read). A device exception - typically a + /// rejected write half - arrives at that same on_read_holding_registers() with the error in its status, + /// exactly as success does, so a subclass overriding that one callback handles both outcomes and never + /// needs to also override on_error(). + bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, + std::span write_values) { + return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count, + write_start_address, write_values)); + } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 4287256101..db21b6e6fd 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -8,10 +8,11 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; // A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, -// and the range [start_address, start_address + quantity) stays inside the 16-bit address space -// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass). +// and the range [start_address, start_address + quantity) stays inside the 16-bit address space. +// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a +// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed. static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) { - return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u; + return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity); } // The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and @@ -307,16 +308,20 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } +// Append a 16-bit value to a PDU in big-endian (wire) byte order. +template static void append_pdu_word(StaticVector &pdu, uint16_t value) { + pdu.push_back(value >> 8); + pdu.push_back(value >> 0); +} + // Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit // fields (start address + quantity for reads and multi-writes, address + value for single writes). template static void append_pdu_header(StaticVector &pdu, FunctionCode function_code, uint16_t first, uint16_t second) { pdu.push_back(static_cast(function_code)); - pdu.push_back(first >> 8); - pdu.push_back(first >> 0); - pdu.push_back(second >> 8); - pdu.push_back(second >> 0); + append_pdu_word(pdu, first); + append_pdu_word(pdu, second); } // Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one @@ -335,7 +340,7 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); return pdu; } - if (uint32_t(start_address) + number_of_entities > 0x10000u) { + if (!address_range_fits(start_address, number_of_entities)) { ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, start_address); return pdu; @@ -378,7 +383,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) // Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), // create_write_registers_pdu(), etc.) which bound their inputs per spec. - if (is_function_code_read(static_cast(function_code))) { + if (is_function_code_read_only(static_cast(function_code))) { if (values != nullptr || values_len > 0) { ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", static_cast(function_code)); @@ -417,7 +422,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, static_cast(function_code)); return pdu; } - if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) { + if (!is_single && !address_range_fits(start_address, number_of_entities)) { ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, start_address); return pdu; @@ -460,29 +465,59 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, return pdu; } +// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not +// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure +// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in +// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each +// failure names its reason, and taking size_t so an oversize span is caught before any narrowing. +static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity, + uint16_t max_quantity) { + if (quantity == 0 || quantity > max_quantity) { + ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity); + return false; + } + if (!address_range_fits(start_address, quantity)) { + ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role), + quantity, start_address); + return false; + } + return true; +} + PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values) { PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) - if (values.empty()) { - ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request"); - return pdu; - } - // Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE. - if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(), - MAX_NUM_OF_REGISTERS_TO_WRITE); - return pdu; - } - if (uint32_t(start_address) + values.size() > 0x10000u) { - ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(), - start_address); + if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) { return pdu; } append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size()); pdu.push_back(static_cast(values.size() * 2)); // byte count for (auto v : values) { - auto decoded_value = decode_value(v); - pdu.push_back(decoded_value[0]); - pdu.push_back(decoded_value[1]); + append_pdu_word(pdu, v); + } + return pdu; +} + +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values) { + PduBuffer pdu; + if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) { + return pdu; + } + if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(), + MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) { + return pdu; + } + // fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values. + const auto write_count = static_cast(write_values.size()); + pdu.push_back(static_cast(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS)); + append_pdu_word(pdu, read_start_address); + append_pdu_word(pdu, read_count); + append_pdu_word(pdu, write_start_address); + append_pdu_word(pdu, write_count); + pdu.push_back(static_cast(write_count * 2)); // byte count + for (auto v : write_values) { + append_pdu_word(pdu, v); } return pdu; } @@ -512,7 +547,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); return; } - if (uint32_t(start_address) + count > 0x10000u) { + if (!address_range_fits(start_address, count)) { ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address); return; } diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index e47a6835cd..c737e206c0 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -11,7 +11,8 @@ namespace esphome::modbus::helpers { -inline bool is_function_code_read(uint8_t function_code) { +// Pure read codes (0x01-0x04): they only read, so they are idempotent and safe to retry. +inline bool is_function_code_read_only(uint8_t function_code) { FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); return masked_function_code == FunctionCode::READ_COILS || masked_function_code == FunctionCode::READ_DISCRETE_INPUTS || @@ -19,12 +20,27 @@ inline bool is_function_code_read(uint8_t function_code) { masked_function_code == FunctionCode::READ_INPUT_REGISTERS; } +// Codes whose response carries read-back data: the pure reads plus 0x17, which reads and writes at once. +inline bool is_function_code_read(uint8_t function_code) { + return is_function_code_read_only(function_code) || + static_cast(function_code & FUNCTION_CODE_MASK) == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// Codes that mutate registers or coils: the pure writes, 0x16 mask-write, and 0x17 read/write multiple. inline bool is_function_code_write(uint8_t function_code) { FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); return masked_function_code == FunctionCode::WRITE_SINGLE_COIL || masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER || masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS || - masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS; + masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS || + masked_function_code == FunctionCode::MASK_WRITE_REGISTER || + masked_function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// True if [start_address, start_address + count) fits within the 16-bit Modbus address space. The 32-bit +// promotion is the overflow guard - a 16-bit sum could wrap and pass. +inline bool address_range_fits(uint16_t start_address, size_t count) { + return uint32_t(start_address) + count <= 0x10000u; } inline bool is_function_code_exception(uint8_t function_code) { @@ -90,8 +106,8 @@ inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { } /** Returns the payload portion of a server response PDU: the bytes after the function code, and for the - * standard read responses (0x01-0x04) also after the byte-count byte. Responses to 0x14/0x17 also carry a - * byte-count byte, but those codes are not implemented and their count byte is left in the payload. For + * read responses (0x01-0x04 and 0x17) also after the byte-count byte. Response 0x14 also carries a + * byte-count byte, but that code is not implemented and its count byte is left in the payload. For * an exception PDU the payload is the exception code byte (the read check must not see the masked * function code, or an exception-of-read would classify as a read and return an empty span). Returns an * empty span if the PDU is too short. @@ -432,6 +448,21 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, */ PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values); +/** Create modbus read/write multiple registers command + * Function 0x17 Read/Write Multiple Registers + * Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17); + * the response carries only the read registers. + * @param read_start_address modbus address of the first register to read back + * @param read_count number of registers to read (at most MAX_NUM_OF_REGISTERS_TO_READ) + * @param write_start_address modbus address of the first register to write + * @param write_values register values to write; the register count is write_values.size() (at most + * MAX_NUM_OF_REGISTERS_TO_WRITE_RW). Any contiguous uint16_t container converts. + * @return PDU (function code + data, no address, no CRC); an empty PDU on any out-of-range input + */ +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values); + /** Create modbus write single register command * Function 0x06 Write Single Register * @param start_address modbus address of the register to write diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 52a61cacad..bb113d649c 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -28,9 +28,12 @@ CONF_ON_NO_RESPONSE = "on_no_response" CONF_ON_NOT_SENT = "on_not_sent" CONF_ON_SENT = "on_sent" CONF_PDU = "pdu" +CONF_READ_ADDRESS = "read_address" +CONF_READ_COUNT = "read_count" CONF_RETRY = "retry" CONF_START_ADDRESS = "start_address" CONF_VALUES = "values" +CONF_WRITE_ADDRESS = "write_address" modbus_client_ns = cg.esphome_ns.namespace("modbus_client") ModbusClientSendAction = modbus_client_ns.class_( @@ -55,6 +58,9 @@ WriteMultipleRegistersAction = modbus_client_ns.class_( WriteMultipleCoilsAction = modbus_client_ns.class_( "WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice ) +ReadWriteMultipleRegistersAction = modbus_client_ns.class_( + "ReadWriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) # Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers. PackedBits = modbus.modbus_ns.class_("PackedBits") @@ -255,21 +261,30 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): _REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const")) -# Every typed action addresses a register or coil range and reports through the same two reply handlers. -_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend( +# The reply-handler pair every typed-dispatch action reports through. Kept in one place so the +# read/write-multiple schema (which cannot require start_address) shares it instead of drifting. +# Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the hub +# reuses once the handler returns, so a deferring action would resume on freed memory. A reply the +# dispatch gate diverts (not a standard-conformant transaction) arrives at on_custom_response with the +# raw request/response PDUs; real device exceptions still arrive via on_error. +_REPLY_HANDLERS_SCHEMA = cv.Schema( { - cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), - # Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the - # hub reuses once the handler returns, so a deferring action would resume on freed memory. cv.Optional(CONF_ON_RESPONSE): _handler_schema(), - # A reply the dispatch gate diverts (not a standard-conformant transaction) arrives here with the - # raw request/response PDUs; real device exceptions still arrive via on_error. cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(), } ) +# Every typed action addresses a register or coil range and reports through the shared reply handlers. +_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), + } +) -def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: + +def _no_address_overflow( + count_key: str, address_key: str = CONF_START_ADDRESS +) -> Callable[[ConfigType], ConfigType]: """Reject a range that runs past the 16-bit address space, which the device could never answer. Only literal configurations can be checked: either operand may be a lambda, and its value is not known @@ -278,17 +293,17 @@ def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: """ def validate(config: ConfigType) -> ConfigType: - start = config[CONF_START_ADDRESS] + start = config[address_key] count = config[count_key] if isinstance(start, Lambda) or isinstance(count, Lambda): return config - # CONF_COUNT is a number; CONF_VALUES is the list whose length is the count. + # A count key holds a number; a values key holds the list whose length is the count. length = count if isinstance(count, int) else len(count) if start + length > 0x10000: raise cv.Invalid( - f"{CONF_START_ADDRESS} 0x{start:04X} plus {length} entities runs past the end of the " + f"{address_key} 0x{start:04X} plus {length} entities runs past the end of the " f"16-bit address space (last addressable entity is 0xFFFF)", - path=[CONF_START_ADDRESS], + path=[address_key], ) return config @@ -468,3 +483,64 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args): arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed)) cg.add(var.set_values_static(arr, len(values))) return await register_client_action(var, config, args, []) + + +# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single +# transaction, so it has two address ranges and uses read_address/write_address instead of start_address. +# Note the two meanings of `values`: here it is the block being WRITTEN, while in on_response the lambda +# argument `values` is the block that was READ BACK (host-order words, the same shape as +# read_holding_registers, so a caller can feed it through the same handler). +_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( + _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_READ_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Optional(CONF_READ_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_READ) + ), + cv.Required(CONF_WRITE_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Required(CONF_VALUES): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint16_t), + cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), + ) + ), + } + ), + _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), + _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), +) + + +@automation.register_action( + "modbus_client.read_write_multiple_registers", + ReadWriteMultipleRegistersAction, + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + cg.add( + var.set_read_address( + await cg.templatable(config[CONF_READ_ADDRESS], args, cg.uint16) + ) + ) + cg.add( + var.set_read_count( + await cg.templatable(config[CONF_READ_COUNT], args, cg.uint16) + ) + ) + cg.add( + var.set_write_address( + await cg.templatable(config[CONF_WRITE_ADDRESS], args, cg.uint16) + ) + ) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index f9a00d65f6..7e6d9d069f 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -332,4 +332,56 @@ template class WriteMultipleCoilsAction : public TypedClientActi } values_; }; +/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in +/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. +template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, read_address) + TEMPLATABLE_VALUE(uint16_t, read_count) + TEMPLATABLE_VALUE(uint16_t, write_address) + + /// Static config: the write registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the write registers are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t read_start = this->read_address_.value(x...); + const uint16_t read_count = this->read_count_.value(x...); + const uint16_t write_start = this->write_address_.value(x...); + // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, std::span(values))); + } + // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many write registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + } // namespace esphome::modbus_client diff --git a/tests/components/modbus/modbus_client_device_test.cpp b/tests/components/modbus/modbus_client_device_test.cpp index 38c28ce2df..333da5b228 100644 --- a/tests/components/modbus/modbus_client_device_test.cpp +++ b/tests/components/modbus/modbus_client_device_test.cpp @@ -118,6 +118,35 @@ TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) { EXPECT_FALSE(call.status.has_value()); } +// FC 0x17: the response carries only the read block, so it decodes as a holding-register read of the read +// start/count. The write half has no client-side ack callback - it is confirmed by a successful response. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersDeliversReadBlockAsHolding) { + RecordingDevice device; + // read 2 regs at 0x0010, write 1 reg (0x00FF) at 0x0020 + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x04, 0x00, 0x2A, 0x01, 0x00}; // read-back: 0x002A, 0x0100 + device.on_response(request, response); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x0010); // the READ start address, not the write + EXPECT_EQ(call.registers, (std::vector{0x002A, 0x0100})); + EXPECT_FALSE(call.status.has_value()); + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); // no separate write-ack on the client side +} + +// A 0x17 response shorter than the requested read count is self-consistent but wrong; it must be diverted +// to on_custom_response(), never clamped and delivered as if complete. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersShortResponseGoesToCustom) { + RecordingDevice device; + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x02, 0x00, 0x2A}; // only 1 register, but 2 were requested + device.on_response(request, response); + + EXPECT_TRUE(device.holding_calls.empty()); + EXPECT_EQ(device.custom_requests.size(), 1u); +} + TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) { GenericDevice device; const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01}; diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 6a65c3bf68..53f51b016b 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -483,6 +483,71 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) { EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty()); } +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) { + const uint16_t write_values[] = {0x000B, 0x0016}; + // Read 2 registers at 0x0010, write 2 registers at 0x0020. + auto pdu = create_read_write_multiple_registers_pdu(0x0010, 2, 0x0020, write_values); + const std::vector expected{0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, + 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); +} + +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduRejectsOutOfRange) { + const uint16_t one_value[] = {0x0001}; + const uint16_t two_values[] = {0x0001, 0x0002}; + // Read count out of range (zero and above the read ceiling). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 0, 0x0020, one_value).empty()); + EXPECT_TRUE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1, 0x0020, one_value).empty()); + // Write count out of range (empty, and above the read/write ceiling which is lower than a plain write). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, std::span()).empty()); + std::vector too_many(MAX_NUM_OF_REGISTERS_TO_WRITE_RW + 1, 0xAAAA); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, too_many).empty()); + // Both blocks at their respective ceilings are accepted. + std::vector at_write_limit(MAX_NUM_OF_REGISTERS_TO_WRITE_RW, 0xAAAA); + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ, 0x0020, at_write_limit).empty()); + // A block that runs past the 16-bit address space is refused (read block, then write block). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0xFFFF, 2, 0x0020, one_value).empty()); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 2, 0xFFFF, two_values).empty()); + // Accept boundary: a block ending exactly at 0x10000 (last register 0xFFFF) still fits. + EXPECT_FALSE(create_read_write_multiple_registers_pdu(0xFFFE, 2, 0x0000, one_value).empty()); // read ends at 0x10000 + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, 1, 0xFFFF, one_value).empty()); // write ends at 0x10000 +} + +TEST(ModbusFunctionCodeClass, ReadWriteMultipleCountsAsBothReadAndWrite) { + const auto rw = static_cast(FC::READ_WRITE_MULTIPLE_REGISTERS); + // 0x17 both reads and writes, but it is not a pure (retry-safe) read. + EXPECT_TRUE(is_function_code_read(rw)); + EXPECT_TRUE(is_function_code_write(rw)); + EXPECT_FALSE(is_function_code_read_only(rw)); + // Pure reads are read and read-only, never write. + const auto rd = static_cast(FC::READ_HOLDING_REGISTERS); + EXPECT_TRUE(is_function_code_read(rd)); + EXPECT_TRUE(is_function_code_read_only(rd)); + EXPECT_FALSE(is_function_code_write(rd)); + // Plain writes are write only. + const auto wr = static_cast(FC::WRITE_MULTIPLE_REGISTERS); + EXPECT_TRUE(is_function_code_write(wr)); + EXPECT_FALSE(is_function_code_read(wr)); + EXPECT_FALSE(is_function_code_read_only(wr)); + // Mask-write register mutates via read-modify-write, so it classes as a write, never a read. + const auto mask = static_cast(FC::MASK_WRITE_REGISTER); + EXPECT_TRUE(is_function_code_write(mask)); + EXPECT_FALSE(is_function_code_read(mask)); + EXPECT_FALSE(is_function_code_read_only(mask)); +} + +TEST(ModbusCreateClientPdu, ReadWriteMultipleReturnsEmpty) { + // The generic builder cannot express 0x17's two blocks; callers use the dedicated builder instead. + const uint16_t values[] = {0x0001}; + EXPECT_TRUE(create_client_pdu(FC::READ_WRITE_MULTIPLE_REGISTERS, 0x0000, 1, reinterpret_cast(values), + sizeof(values)) + .empty()); +} + TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) { // The container overload appends - the semantic every migrated caller relies on when a lambda // has already put words into the buffer. diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index cae2002342..bce2149fbf 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -35,6 +35,8 @@ button: id(bare_client).write_single_register(0x10, 42); id(bare_client).write_single_coil(0x01, true); id(bare_client_explicit_hub).read_holding_registers(0x20, 4); + const uint16_t rw_vals[] = {1, 2}; + id(bare_client).read_write_multiple_registers(0x0400, 2, 0x0300, rw_vals); - platform: template name: "Send Read" on_press: @@ -134,3 +136,13 @@ button: on_error: then: - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0300 + values: !lambda "return {1, 2};" + read_address: 0x0400 + read_count: 2 + on_response: + then: + # `values` here is the READ-BACK block, not the written block above + - lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml new file mode 100644 index 0000000000..1f89889c95 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml @@ -0,0 +1,111 @@ +esphome: + name: uart-mock-modbus-cli-rw + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Two virtual buses looped back to each other: the client's transmissions reach the server and the +# server's replies reach the client. auto_start so forwarding is active before the button fires. +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + # Writable + readable register: the read publishes what it returns, so the test can confirm the + # write half of the 0x17 ran before the read half (Modbus 6.17). + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(srv_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(srv_write_1).publish_state(x); + return true; + # Read-only register, returned together with 0x01 by the 2-register read half. + - address: 0x02 + value_type: U_WORD + read_lambda: return 0x00AA; + +sensor: + # Server-side observations. + - platform: template + name: "srv_write_1" + id: srv_write_1 + - platform: template + name: "srv_read_1" + id: srv_read_1 + # Client-side read-back: the values the client's on_response received. + - platform: template + name: "client_read_0" + id: client_read_0 + - platform: template + name: "client_read_1" + id: client_read_1 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. + - modbus_client.read_write_multiple_registers: + address: 0x01 + read_address: 0x0001 + read_count: 2 + write_address: 0x0001 + values: [0x1234] + on_response: + then: + - lambda: |- + // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. + if (values.size() >= 2) { + id(client_read_0).publish_state(values[0]); + id(client_read_1).publish_state(values[1]); + } diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index ca0041cc5b..1994d02c34 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -756,3 +756,38 @@ async def test_uart_mock_modbus_fairness( f"controllers did not get a fair share of the bus: " f"controller 1 issued {count_1}, controller 2 issued {count_2}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A modbus_client.read_write_multiple_registers action (FC 0x17) drives a server end to end. + + The client writes reg 0x0001 = 0x1234 and reads regs 0x0001..0x0002 in one transaction; the server + applies the write first (Modbus 6.17). The test confirms both ends: the server's write_lambda ran + (srv_write_1) and the read half came back to the client's on_response (client_read_0 = the + just-written 0x1234, client_read_1 = the read-only 0x00AA). + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] + ) + futures = tracker.expect_all( + { + "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 + "client_read_0": 4660, # client read reg 0x0001 back as the just-written 0x1234 + "client_read_1": 170, # client read reg 0x0002 (0x00AA) in the same request + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 82a63658f9da55eed7ecd80b6a9b8a1b470ac6a0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 11 Aug 2026 00:20:20 +0200 Subject: [PATCH 074/597] =?UTF-8?q?[hoermann=5Fhcp]=20Add=20H=C3=B6rmann?= =?UTF-8?q?=20HCP=20garage=20door=20component=20(#17355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/hoermann_hcp/__init__.py | 33 ++ .../components/hoermann_hcp/cover/__init__.py | 22 + .../hoermann_hcp/cover/hoermann_hcp_cover.cpp | 87 ++++ .../hoermann_hcp/cover/hoermann_hcp_cover.h | 27 ++ .../components/hoermann_hcp/hoermann_hcp.cpp | 336 ++++++++++++++ .../components/hoermann_hcp/hoermann_hcp.h | 110 +++++ script/analyze_component_buses.py | 1 + tests/components/hoermann_hcp/common.yaml | 8 + .../cover/hoermann_hcp_cover_test.cpp | 174 +++++++ .../hoermann_hcp/hoermann_hcp_test.cpp | 430 ++++++++++++++++++ .../hoermann_hcp/test.esp32-idf.yaml | 3 + .../hoermann_hcp/test.esp8266-ard.yaml | 3 + tests/test_build_components/common/README.md | 5 +- .../common/modbus_server/esp32-idf.yaml | 10 + .../common/modbus_server/esp8266-ard.yaml | 10 + 16 files changed, 1259 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hoermann_hcp/__init__.py create mode 100644 esphome/components/hoermann_hcp/cover/__init__.py create mode 100644 esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp create mode 100644 esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h create mode 100644 esphome/components/hoermann_hcp/hoermann_hcp.cpp create mode 100644 esphome/components/hoermann_hcp/hoermann_hcp.h create mode 100644 tests/components/hoermann_hcp/common.yaml create mode 100644 tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp create mode 100644 tests/components/hoermann_hcp/hoermann_hcp_test.cpp create mode 100644 tests/components/hoermann_hcp/test.esp32-idf.yaml create mode 100644 tests/components/hoermann_hcp/test.esp8266-ard.yaml create mode 100644 tests/test_build_components/common/modbus_server/esp32-idf.yaml create mode 100644 tests/test_build_components/common/modbus_server/esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9bcbe087c5..253b0c05b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 +esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@zweckj"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True + +CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" + +hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp") +HoermannHcp = hoermann_hcp_ns.class_( + "HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice +) + +# The Hoermann UAP module answers on Modbus server address 2. +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)}) + .extend(cv.polling_component_schema("500ms")) + .extend(modbus.modbus_device_schema(0x02, role="server")) +) + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device( + "hoermann_hcp", role="server" +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component) + +CONFIG_SCHEMA = ( + cover.cover_schema(HoermannHcpCover) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await cover.new_cover(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -0,0 +1,87 @@ +#include "hoermann_hcp_cover.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.cover"; + +cover::CoverTraits HoermannHcpCover::get_traits() { + cover::CoverTraits traits; + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void HoermannHcpCover::setup() { + // Nothing is published before the bus controller is heard from, and the untouched position reads as fully + // open, so flag the entity until the first contact clears it again. + this->status_set_warning("waiting for the bus controller"); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); } + +void HoermannHcpCover::control(const cover::CoverCall &call) { + bool accepted = true; + if (call.get_stop()) + accepted &= this->parent_->stop_door(); + if (call.get_toggle().has_value()) + accepted &= this->parent_->impulse_door(); + if (const auto position = call.get_position()) + accepted &= this->parent_->set_position(*position); + if (!accepted) { + // The command never reached the door, so publish the unchanged state over the one the caller assumed. + ESP_LOGW(TAG, "Command was not accepted by the door"); + this->publish_state(false); + } +} + +void HoermannHcpCover::update_from_state_() { + if (!this->parent_->is_valid()) { + this->status_set_warning(); + // The door can now move unheard, so drop the baseline a direction would be inferred from and stop + // reporting motion instead of leaving the cover travelling until the controller returns. + this->previous_position_ = NAN; + if (this->current_operation != cover::COVER_OPERATION_IDLE) { + this->current_operation = cover::COVER_OPERATION_IDLE; + this->publish_state(); + } + return; + } + this->status_clear_warning(); + + const auto previous_operation = this->current_operation; + const float current_position = this->parent_->get_current_position(); + switch (this->parent_->get_door_state()) { + case DoorState::OPENING: + this->current_operation = cover::COVER_OPERATION_OPENING; + break; + case DoorState::CLOSING: + this->current_operation = cover::COVER_OPERATION_CLOSING; + break; + case DoorState::MOVE_VENTING: + case DoorState::MOVE_HALF: + // These states carry no direction, so keep the current one until the position actually moves. + if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) { + this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING + : cover::COVER_OPERATION_CLOSING; + } + break; + default: + this->current_operation = cover::COVER_OPERATION_IDLE; + break; + } + this->previous_position_ = current_position; + + // Compare against the position last published, which starts at COVER_OPEN rather than at zero. + const bool changed = this->position != current_position || previous_operation != this->current_operation; + this->position = current_position; + if (changed) { + // The bus reports the position on every broadcast, so nothing here is worth restoring from flash. + this->publish_state(false); + } +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpCover : public cover::Cover, public Component { + public: + explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + cover::CoverTraits get_traits() override; + void control(const cover::CoverCall &call) override; + + protected: + void update_from_state_(); + HoermannHcp *const parent_; + // NAN until the first position is observed, so no direction is inferred from a baseline that never existed. + float previous_position_{NAN}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..0dc146a061 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,336 @@ +#include "hoermann_hcp.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp"; + +// Hoermann HCP holding-register blocks. +static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller +static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller +static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller +static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; +static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; + +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; + +// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because +// its low byte tells a plain stop from the vent position. +struct DoorStateMapping { + uint8_t code; + DoorState state; +}; +static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = { + {0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF}, + {0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN}, + {0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN}, +}; + +// The hub rejects a reply whose register count does not match the request, so an unrecognized block length +// is padded with zeros rather than answered with an exception that would fail the controller's whole poll. +static void push_zeros(modbus::RegisterValues ®isters, uint16_t count) { + for (uint16_t i = 0; i < count; i++) + registers.push_back(0x0000); +} + +// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving. +static bool is_moving(DoorState state) { + switch (state) { + case DoorState::OPENING: + case DoorState::CLOSING: + case DoorState::MOVE_HALF: + case DoorState::MOVE_VENTING: + return true; + default: + return false; + } +} + +void HoermannHcp::update() { + const uint32_t now = millis(); + // Time out the connection flag if the bus controller stopped polling. + if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_) + this->set_valid_(false); + // Status broadcasts alone keep the connection alive, so a command the controller never fetches would + // otherwise block every later one for as long as it keeps broadcasting. + if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + this->next_command_ = nullptr; + this->command_written_at_ = 0; + this->clear_target_(); + } + // A target waits for a door still travelling the other way to turn around. If it never does, the target has + // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. + if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); + this->clear_target_(); + } + if (this->changed_) { + this->changed_ = false; + this->state_callback_.call(); + } +} + +void HoermannHcp::dump_config() { + ESP_LOGCONFIG(TAG, + "Hoermann HCP bridge:\n" + " Modbus server address: 0x%02X", + this->get_address()); +} + +modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + if (start_address != STATE_REG) { + ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message + // counter (high byte) and command (low byte). The read length identifies which internal block is requested. + const uint16_t counter = this->command_reg_value_ & 0xFF00; + const uint16_t command = static_cast((this->command_reg_value_ & 0x00FF) << 8); + + switch (number_of_registers) { + case 8: + // Command request: return the internal state, injecting any pending command. + registers.push_back(counter); + registers.push_back(static_cast(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(0x0004 | counter)); + registers.push_back(command); + break; + case 5: + // Bus scan (the bus controller discovering us, typically at startup). + ESP_LOGD(TAG, "Bus scan received from bus controller"); + registers.push_back(counter); + registers.push_back(static_cast(0x0005 | command)); + registers.push_back(0x0430); + registers.push_back(0x10FF); + registers.push_back(0xA845); + break; + default: + ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers); + push_zeros(registers, number_of_registers); + break; + } + + return {}; +} + +modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + if (start_address == COMMAND_REG) { + // 0x17 write half: stash the command register so the following read half can echo its message counter and + // command byte back from STATE_REG. The hub always runs the write before the read within one request. + this->record_response_(); + this->command_reg_value_ = registers[0]; + return {}; + } + + if (start_address != BROADCAST_REG) { + // Every device sees every broadcast, so a frame meant for another node is ordinary traffic + ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new + // position checks the target against the new state. + if (registers.size() > 2) + this->on_state_reg_(registers[2]); + if (registers.size() > 1) + this->on_position_reg_(registers[1]); + return {}; +} + +void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) { + push_zeros(registers, 2); + return; + } + if (this->command_written_at_ == 0) { + // First read after the command was queued: present the "key pressed" values. + this->command_written_at_ = millis(); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->pressed_value); + registers.push_back(0x0000); + return; + } + if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { + // Still inside the key-press window, so keep presenting 0x0000. + push_zeros(registers, 2); + return; + } + // Enough time passed: present the "key released" values and clear the command. + ESP_LOGD(TAG, "Released '%s' command", command->name); + this->command_written_at_ = 0; + this->next_command_ = nullptr; + registers.push_back(command->released_value); + registers.push_back(0x0000); +} + +void HoermannHcp::on_position_reg_(uint16_t value) { + // Low byte: current position. + const uint8_t position = static_cast(value); + if (this->position_raw_ == position) + return; + + this->position_raw_ = position; + this->update_current_position_(); + // Until the door actually travels the way it was told to, its position says nothing about the target. + if (!this->has_target_() || !this->target_started_) + return; + + // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way. + const bool reached = this->target_direction_ == DoorState::OPENING + ? this->current_position_ >= this->target_position_ + : this->current_position_ <= this->target_position_; + if (reached) + this->stop_door(); +} + +void HoermannHcp::on_state_reg_(uint16_t value) { + // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte. + const uint16_t previous = this->prev_state_reg_; + this->prev_state_reg_ = value; + if (previous == value) + return; + + const uint8_t state = value >> 8; + if (state == 0x00) { + // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop. + this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED); + return; + } + for (const auto &mapping : DOOR_STATE_MAPPINGS) { + if (mapping.code == state) { + this->set_door_state_(mapping.state); + return; + } + } + // The low byte can change on its own, so only report a state we cannot decode once. + if (state != (previous >> 8)) + ESP_LOGW(TAG, "Unknown door state 0x%02X", state); +} + +bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { + if (!this->valid_) { + // Queueing now would fire the command whenever the controller comes back, which may be much later. + ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); + return false; + } + if (this->next_command_ != nullptr) { + ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); + return false; + } + // A new command supersedes any half-open target the door was still travelling to. + this->clear_target_(); + this->next_command_ = &command; + this->command_queued_at_ = millis(); + return true; +} + +bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } + +bool HoermannHcp::stop_door() { + if (!is_moving(this->door_state_)) { + this->clear_target_(); + return true; + } + // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. + return this->queue_command_(COMMAND_IMPULSE); +} + +bool HoermannHcp::set_position(float position) { + // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed. + if (position <= CLOSE_POSITION_THRESHOLD) + return this->close_door(); + if (position >= OPEN_POSITION_THRESHOLD) + return this->open_door(); + // Asking the door to travel to where it already is means stopping it. + if (position == this->current_position_) + return this->stop_door(); + + // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. + const bool opening = position > this->current_position_; + if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) + return false; + this->target_position_ = position; + this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; + // A door already travelling that way is on its way; one moving the other way has to turn around first. + this->target_started_ = this->door_state_ == this->target_direction_; + return true; +} + +void HoermannHcp::record_response_() { + this->last_response_ = millis(); + this->set_valid_(true); +} + +void HoermannHcp::set_valid_(bool valid) { + if (this->valid_ == valid) + return; + this->valid_ = valid; + this->changed_ = true; + if (valid) { + ESP_LOGI(TAG, "Bus controller connected"); + return; + } + ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); + // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->next_command_ = nullptr; + this->command_written_at_ = 0; + this->clear_target_(); +} + +void HoermannHcp::set_door_state_(DoorState state) { + if (this->door_state_ == state) + return; + this->door_state_ = state; + this->changed_ = true; + this->update_current_position_(); + if (!this->has_target_()) + return; + if (state == this->target_direction_) { + this->target_started_ = true; + } else if (this->target_started_ && !is_moving(state)) { + // The door came to rest without reaching the target, so the request it belonged to is over. + this->clear_target_(); + } +} + +void HoermannHcp::update_current_position_() { + // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so + // trust the reported end stop over the raw count. + float position = static_cast(this->position_raw_) / 200.0f; + if (this->door_state_ == DoorState::CLOSED) { + position = 0.0f; + } else if (this->door_state_ == DoorState::OPEN) { + position = 1.0f; + } + if (this->current_position_ != position) { + this->current_position_ = position; + this->changed_ = true; + } +} + +void HoermannHcp::clear_target_() { + this->target_position_ = 0.0f; + this->target_started_ = false; +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..142365f16e --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,110 @@ +#pragma once + +#include + +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::hoermann_hcp { + +// Door state as reported by the Hoermann bus controller. +enum class DoorState : uint8_t { + OPEN, + OPENING, + CLOSED, + CLOSING, + HALF_OPEN, + MOVE_VENTING, + VENT, + MOVE_HALF, + STOPPED, +}; + +// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a +// short delay the released value. The second command register remains zero. +struct HoermannHcpCommand { + const char *name; + uint16_t pressed_value; + uint16_t released_value; +}; + +class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { + public: + void update() override; + void dump_config() override; + + // Registered by child entities to be notified when the door state changes. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + + // Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write + // half first, storing the command register that the read half echoes back) and broadcasts status with 0x10. + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) override; + modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) override; + + // Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus + // controller cannot be asked right now, so the caller can react. + bool open_door(); + bool close_door(); + bool impulse_door(); + bool stop_door(); + bool set_position(float position); + + DoorState get_door_state() const { return this->door_state_; } + float get_current_position() const { return this->current_position_; } + bool is_valid() const { return this->valid_; } + + protected: + void record_response_(); + // Returns false when the bus controller has not fetched the previous command yet. + bool queue_command_(const HoermannHcpCommand &command); + // Appends the two key-press registers and advances the pending command's press/release state. + void push_command_registers_(modbus::RegisterValues ®isters); + void on_position_reg_(uint16_t value); + void on_state_reg_(uint16_t value); + + void set_valid_(bool valid); + void set_door_state_(DoorState state); + // Recomputes the reported position from position_raw_ and the current door state. + void update_current_position_(); + bool has_target_() const { return this->target_position_ != 0.0f; } + void clear_target_(); + + CallbackManager state_callback_; + + float current_position_{0.0f}; + // Position the door was told to travel to; 0.0 means no target is armed. + float target_position_{0.0f}; + + // Pending command / key-press state machine. + const HoermannHcpCommand *next_command_{nullptr}; + uint32_t command_queued_at_{0}; + uint32_t command_written_at_{0}; + uint32_t last_response_{0}; + + // A command is "pressed" for this long before its end value is sent. + uint16_t key_press_delay_ms_{100}; + // Drop the "connected" flag if the bus controller has not polled us for this long. + uint16_t connection_timeout_ms_{2000}; + // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when + // it reads 0x0000. + uint16_t prev_state_reg_{0xFFFF}; + // 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message + // counter and low-byte command back from STATE_REG. + uint16_t command_reg_value_{0}; + + DoorState door_state_{DoorState::CLOSED}; + // Direction the door was started in for the current target. A target armed while the door is still travelling + // the other way must not be judged by the reported direction until the door has turned around. + DoorState target_direction_{DoorState::STOPPED}; + // Position as reported by the bus controller, 0..200 across the full travel. + uint8_t position_raw_{0}; + bool target_started_{false}; + bool valid_{false}; + bool changed_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a6ccb79544..b8ee3066bd 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -52,6 +52,7 @@ COMMON_BUS_PATH = ( # the packages on the right as well PACKAGE_DEPENDENCIES = { "modbus": ["uart"], # modbus packages include uart packages + "modbus_server": ["uart"], # modbus_server packages include uart packages # Add more package dependencies here as needed } diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml new file mode 100644 index 0000000000..3b77eed7ea --- /dev/null +++ b/tests/components/hoermann_hcp/common.yaml @@ -0,0 +1,8 @@ +hoermann_hcp: + id: hoermann_hcp_hub + modbus_id: modbus_server_bus + +cover: + - platform: hoermann_hcp + name: Garage Door + device_class: garage diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp new file mode 100644 index 0000000000..0ec2ed1ddd --- /dev/null +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -0,0 +1,174 @@ +#include + +#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// The door only accepts commands once the bus controller has actually talked to it. +void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } + +// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. +uint16_t poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + return response.size() == 8u ? response[2] : 0xFFFF; +} + +} // namespace + +// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. +TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN); + + // Any request marks the device connected, which is itself a state change. + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED); +} + +// Venting and half-open moves report no direction, so one is only derived once the position has moved. +TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // Position 100/200 = 0.5, state 0x80 -> resting half open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000})); + door.update(); + ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // State 0x05 -> moving to half-open, but the position has not moved yet. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // Position 120/200 = 0.6 is higher than before, so the door is opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING); + EXPECT_FLOAT_EQ(cover.position, 0.6f); +} + +// Booting while the door is already mid-move gives no baseline to compare against, so no direction +// may be inferred from the first update. +TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // The very first thing seen is a half-open move already at 100/200 = 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); +} + +// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather +// than as a target the door would be stopped at. +TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_open().perform(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed +} + +// The same for cover.close, which arrives as a position of 0.0. +TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_close().perform(); + EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed +} + +TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_toggle().perform(); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + // The door is opening, so it takes an impulse to stop it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + + cover.make_call().set_command_stop().perform(); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// A position between the end stops starts the door in the right direction; it is stopped there later. +TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { + HoermannHcp door; // starts out fully closed + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_position(0.5f).perform(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed +} + +// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has +// to be published back over that assumption. +TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + + cover.make_call().set_command_close().perform(); + + EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); +} + +// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would +// otherwise sit at its fully open default and look healthy. +TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + EXPECT_TRUE(cover.status_has_warning()); + + connect(door); + door.update(); + EXPECT_FALSE(cover.status_has_warning()); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp new file mode 100644 index 0000000000..8463c3f605 --- /dev/null +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -0,0 +1,430 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// The device only accepts commands once the bus controller has actually talked to it. +void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } + +// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. +uint16_t poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + return response.size() == 8u ? response[2] : 0xFFFF; +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::set_valid_; +}; + +} // namespace + +// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. +TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 2, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 2u); + EXPECT_EQ(response[0], 0x0004); + EXPECT_EQ(response[1], 0x0000); +} + +// A bus scan (write 3 / read 5) answers with the fixed device identification block. +TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 5, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 5u); + EXPECT_EQ(response[1], 0x0005); + EXPECT_EQ(response[2], 0x0430); + EXPECT_EQ(response[3], 0x10ff); + EXPECT_EQ(response[4], 0xa845); +} + +// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press. +TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0001); + EXPECT_EQ(response[2], 0x0000); + EXPECT_EQ(response[3], 0x0000); +} + +// A queued control command is injected into the next command poll as a simulated key press. +TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { + HoermannHcp door; + connect(door); + door.open_door(); + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value + EXPECT_EQ(response[3], 0x0000); +} + +// A read of any other block is an addressing error rather than a successful all-zero reply. +TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { + HoermannHcp door; + RegisterValues response; + EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A command is held for the key-press duration, then released, and only then can the next one be queued. +TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { + TestableHoermannHcp door; + connect(door); + door.open_door(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. + door.close_door(); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + // With the command gone, the next one is accepted again. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed +} + +// Commands issued while the bus controller is absent are dropped instead of firing when it returns. +TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { + HoermannHcp door; + door.open_door(); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// Losing the controller must drop a command it never fetched, otherwise it blocks every later command +// and fires unasked once the bus comes back. +TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { + TestableHoermannHcp door; + connect(door); + door.open_door(); + ASSERT_TRUE(door.is_valid()); + + door.set_valid_(false); + EXPECT_FALSE(door.is_valid()); + + // The reconnecting poll must not replay the dropped command. + EXPECT_EQ(poll_command(door), 0x0000); + // And the slot is free, so a new command is accepted. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); +} + +// The connection is dropped by update() once the controller stops polling, which is what releases a +// command it never fetched in the field. +TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { + TestableHoermannHcp door; + // Wide enough that a stall cannot expire the connection before the check below runs. + door.connection_timeout_ms_ = 10000; + connect(door); + door.open_door(); + + // Still inside the window: the controller counts as present. + door.update(); + ASSERT_TRUE(door.is_valid()); + + // Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer. + door.connection_timeout_ms_ = 20; + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + EXPECT_FALSE(door.is_valid()); + // The pending command went with the connection instead of firing on the reconnecting poll. + EXPECT_EQ(poll_command(door), 0x0000); +} + +// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to +// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely. +TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect(door); + door.open_door(); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // A status broadcast refreshes the connection without ever fetching the command. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + ASSERT_TRUE(door.is_valid()); + + // With the stale command gone, the door accepts commands again. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); +} + +// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed +// differently per block length. +TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) { + HoermannHcp door; + // Counter 0x34 in the high byte, command 0x07 in the low byte. + door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000})); + + RegisterValues command_poll; + door.on_read_holding_registers(STATE_REG, 8, command_poll); + ASSERT_EQ(command_poll.size(), 8u); + EXPECT_EQ(command_poll[0], 0x3400); // counter alone + EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low + + RegisterValues empty_poll; + door.on_read_holding_registers(STATE_REG, 2, empty_poll); + ASSERT_EQ(empty_poll.size(), 2u); + EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here + EXPECT_EQ(empty_poll[1], 0x0700); // command alone + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + ASSERT_EQ(scan.size(), 5u); + EXPECT_EQ(scan[0], 0x3400); + EXPECT_EQ(scan[1], 0x0705); +} + +// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position. +TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) { + HoermannHcp door; + // registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening). + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a +// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed. +TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) { + HoermannHcp door; + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::STOPPED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of +// the state register still has to be decoded. +TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) { + HoermannHcp door; + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061})); + EXPECT_EQ(door.get_door_state(), DoorState::VENT); +} + +// A door parking a count short of its end stop must still report exactly closed or open, because +// Cover::is_fully_closed() compares against 0.0 exactly. +TEST(HoermannHcpWrite, EndStopsReportExactPositions) { + HoermannHcp door; + // Position register 1 of 200 while the door reports itself closed. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f); + + // Position register 199 of 200 while the door reports itself open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f); + + // Away from the end stops the raw count is reported as-is. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// A position request below the lower snap threshold becomes a plain close command. +TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { + HoermannHcp door; + connect(door); + door.set_position(0.02f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value +} + +// A half-open target starts the door moving towards the requested position. +TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { + HoermannHcp door; // starts out fully closed + connect(door); + door.set_position(0.5f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value +} + +// The door has no notion of a target, so it is stopped with an impulse once it travels past the request. +TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + + // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_EQ(poll_command(door), 0x0000); + + // Position 120/200 = 0.6 is past the target, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once +// must be read as "already stopped" rather than "still opening". +TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + // The door is stopped at 0.3 by a wall button, short of the requested 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// A target armed while the door is still travelling the other way must not be judged by that old direction, +// otherwise the very next position it reports counts as reached and stops the door where it stands. +TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { + TestableHoermannHcp door; + connect(door); + // The door is closing, passing 60/200 = 0.3. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + + // Still closing at 58/200 = 0.29: below the target, but not on the way to it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); + EXPECT_EQ(poll_command(door), 0x0000); + + // Now opening at 62/200 = 0.31, still short of the target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); + + // Past the target at 110/200 = 0.55, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// A motor turning around can report a momentary stop; dropping the target there would let the door run on +// to the end stop that the reversing command asked for. +TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { + TestableHoermannHcp door; + connect(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + // The stop reported on the way from closing to opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // The door then opens and still has to be stopped at the requested position. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); +} + +// A door that never turns around has to lose the target as well, otherwise it would cut a later move short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the + // target is the only thing that may expire here. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000})); + door.update(); + ASSERT_TRUE(door.is_valid()); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/test.esp32-idf.yaml b/tests/components/hoermann_hcp/test.esp32-idf.yaml new file mode 100644 index 0000000000..ce3aa2437a --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/hoermann_hcp/test.esp8266-ard.yaml b/tests/components/hoermann_hcp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8f7ba81b5b --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index a3c6f476e0..010313db7f 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -31,11 +31,14 @@ common/ │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml -├── modbus/ # Modbus (includes uart via packages) +├── modbus/ # Modbus client (includes uart via packages) │ ├── esp32-idf.yaml │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml +├── modbus_server/ # Modbus server (includes uart via packages) +│ ├── esp32-idf.yaml +│ └── esp8266-ard.yaml └── ble/ ├── esp32-idf.yaml ├── esp32-ard.yaml diff --git a/tests/test_build_components/common/modbus_server/esp32-idf.yaml b/tests/test_build_components/common/modbus_server/esp32-idf.yaml new file mode 100644 index 0000000000..093467ebfd --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP32 IDF tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp32-idf.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/test_build_components/common/modbus_server/esp8266-ard.yaml b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml new file mode 100644 index 0000000000..ab9cad8b56 --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP8266 Arduino tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp8266-ard.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server From c8d2c3691a1a9fd45f958e64c89b27fcbba0ba80 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 11 Aug 2026 00:58:37 +0200 Subject: [PATCH 075/597] [mitsubishi_cn105] Add vertical vane direction select (#16723) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../mitsubishi_cn105_climate.cpp | 4 +- .../mitsubishi_cn105_component.h | 6 + .../mitsubishi_cn105/select/__init__.py | 47 ++++++++ .../mitsubishi_cn105_vane_select_vertical.cpp | 39 ++++++ .../mitsubishi_cn105_vane_select_vertical.h | 21 ++++ .../climate/mitsubishi_cn105_tests.cpp | 60 +++++----- tests/components/mitsubishi_cn105/common.yaml | 6 + ...bishi_cn105_vane_select_vertical_tests.cpp | 111 ++++++++++++++++++ 8 files changed, 261 insertions(+), 33 deletions(-) create mode 100644 esphome/components/mitsubishi_cn105/select/__init__.py create mode 100644 esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp create mode 100644 esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h create mode 100644 tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 13e02668d1..197e1e1bb5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -133,9 +133,7 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } } - if (this->parent_->is_status_initialized()) { - this->apply_values_(); - } + this->parent_->publish_status(); } void MitsubishiCN105Climate::apply_values_() { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 2319ea7c54..1caf779f40 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -38,6 +38,12 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { this->status_callback_.add(std::forward(callback)); } + void publish_status() { + if (this->is_status_initialized()) { + this->status_callback_.call(); + } + } + protected: MitsubishiCN105 hp_; CallbackManager status_callback_; diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py new file mode 100644 index 0000000000..a2e0353f85 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import ( + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +DEPENDENCIES = ["mitsubishi_cn105"] + +CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" + +# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"] + +MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( + "MitsubishiCN105VerticalVaneDirectionSelect", + select.Select, + cg.Component, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema( + MitsubishiCN105VerticalVaneDirectionSelect, + icon="mdi:arrow-up-down", + ), + } +).extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION): + var = cg.new_Pvariable(vertical_vane_direction[CONF_ID]) + await cg.register_component(var, vertical_vane_direction) + await select.register_select( + var, + vertical_vane_direction, + options=VERTICAL_VANE_DIRECTIONS, + ) + await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp new file mode 100644 index 0000000000..0f9142fe5e --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -0,0 +1,39 @@ +#include "mitsubishi_cn105_vane_select_vertical.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py. +// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based +// Select API, so Python option order and this array must stay aligned. +static constexpr std::array VALUES{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2, + MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, +}; + +void MitsubishiCN105VerticalVaneDirectionSelect::setup() { + this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); }); + if (this->parent_->is_status_initialized()) { + this->publish_vane_state(this->parent_->status().vane_mode); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) { + if (index < VALUES.size()) { + this->parent_->set_vane_mode(VALUES[index]); + this->parent_->publish_status(); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) { + for (size_t i = 0; i < VALUES.size(); ++i) { + if (VALUES[i] == mode) { + this->publish_state(i); + return; + } + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h new file mode 100644 index 0000000000..76977d59d7 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../mitsubishi_cn105_component.h" + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select, + public Component, + public Parented { + public: + void setup() override; + void publish_vane_state(MitsubishiCN105::VaneMode mode); + + protected: + void control(size_t index) override; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7703b02fcd..28fdfbb313 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -2,16 +2,16 @@ namespace esphome::mitsubishi_cn105::testing { -struct TestContext { +struct MitsubishiCN105TestsContext { MockUARTComponent uart; uart::UARTDevice device{&uart}; TestableMitsubishiCN105 sut{device}; - TestContext() { this->sut.set_current_time(0); } + MitsubishiCN105TestsContext() { this->sut.set_current_time(0); } }; TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); @@ -26,7 +26,7 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -106,7 +106,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -133,7 +133,7 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -164,7 +164,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { } TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -228,7 +228,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { } TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); @@ -258,7 +258,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); @@ -273,7 +273,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); @@ -288,7 +288,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); @@ -298,7 +298,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); @@ -308,7 +308,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58}); @@ -320,7 +320,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8}); @@ -332,7 +332,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_power(true); ctx.sut.apply_settings(); @@ -342,7 +342,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_target_temperature(23.0f); ctx.sut.apply_settings(); @@ -352,7 +352,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.use_temperature_encoding_b_ = true; ctx.sut.set_target_temperature(26.0f); @@ -363,7 +363,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.use_temperature_encoding_b_ = true; ctx.sut.set_target_temperature(26.5f); @@ -374,7 +374,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplyModeCool) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); ctx.sut.apply_settings(); @@ -384,7 +384,7 @@ TEST(MitsubishiCN105Tests, ApplyModeCool) { } TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1); ctx.sut.apply_settings(); @@ -394,7 +394,7 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { } TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); ctx.sut.apply_settings(); @@ -404,7 +404,7 @@ TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -414,7 +414,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_high_bit_ = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); @@ -425,7 +425,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { } TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -470,7 +470,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { } TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Set remote temperature ctx.sut.set_remote_temperature(28.5f); @@ -505,7 +505,7 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { } TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. ctx.sut.use_temperature_encoding_b_ = true; @@ -545,7 +545,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { } TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -578,7 +578,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); @@ -591,13 +591,13 @@ TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); } diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5966523b34..12a3b8ce9d 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -10,6 +10,12 @@ climate: name: "AC Test" supported_swing_modes: BOTH +select: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac + vertical_vane_direction: + name: "Vertical Vane" + esphome: on_boot: then: diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp new file mode 100644 index 0000000000..4c980d69d8 --- /dev/null +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -0,0 +1,111 @@ +#include "../common.h" +#include "esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h" + +namespace esphome::mitsubishi_cn105::testing { + +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } +}; + +class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { + public: + using MitsubishiCN105VerticalVaneDirectionSelect::control; +}; + +struct VerticalVaneDirectionSelectTestContext { + TestableMitsubishiCN105Component hub; + TestableMitsubishiCN105VerticalVaneDirectionSelect select; + + VerticalVaneDirectionSelectTestContext() { + this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"}); + this->select.set_parent(&this->hub); + this->select.setup(); + } +}; + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array expected_modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < expected_modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.select.control(i); + EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]); + } +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.hub.mutable_status().vane_mode = modes[i]; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{i}); + } + + ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndClimateThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + ctx.select.control(6); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + ctx.select.control(3); + EXPECT_EQ(ctx.select.active_index(), std::optional{3}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSelectThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{0}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) { + VerticalVaneDirectionSelectTestContext ctx; + + ctx.select.control(3); + + EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3); + EXPECT_FALSE(ctx.select.has_state()); +} + +} // namespace esphome::mitsubishi_cn105::testing From 2a0f2d59f0160700abd6912a883cfd4368aeaccd Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 10 Aug 2026 16:13:12 -0700 Subject: [PATCH 076/597] [modbus_server] Add coil/discrete-input support (#17464) Co-authored-by: Claude Fable 5 --- esphome/components/modbus_server/__init__.py | 54 +++++- esphome/components/modbus_server/const.py | 1 + .../modbus_server/modbus_server.cpp | 101 +++++++++- .../components/modbus_server/modbus_server.h | 47 ++++- .../modbus_server/test_modbus_server.py | 23 ++- tests/components/modbus_server/common.yaml | 10 + .../modbus_server/modbus_server_test.cpp | 182 +++++++++++++++++- .../uart_mock_modbus_client_typed.yaml | 6 +- ...rt_mock_modbus_server_controller_bits.yaml | 147 ++++++++++++++ tests/integration/state_utils.py | 10 +- tests/integration/test_uart_mock_modbus.py | 73 ++++++- 11 files changed, 632 insertions(+), 22 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 14f4ca8a4d..16b956d7b5 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType from .const import ( CONF_ALLOW_PARTIAL_READ, + CONF_BITS, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -34,6 +35,7 @@ ModbusServer = modbus_server_ns.class_( ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_server_ns.struct("ServerRegister") +ServerBit = modbus_server_ns.class_("ServerBit") SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( { @@ -64,6 +66,32 @@ ModbusServerRegisterSchema = cv.Schema( ) +ModbusServerBitSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerBit), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +def _validate_unique_bit_addresses(config: ConfigType) -> ConfigType: + # Coils and discrete inputs share one bit address space (like holding/input registers share the + # register table), so each bit address may appear only once. + seen: set[int] = set() + for bit in config.get(CONF_BITS, []): + address = bit[CONF_ADDRESS] + if address in seen: + raise cv.Invalid( + f"Bit address 0x{address:04X} is configured more than once; coils and discrete " + "inputs share one bit address space, so each address must be unique", + path=[CONF_BITS], + ) + seen.add(address) + return config + + def _validate_register_ranges(config: ConfigType) -> ConfigType: # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit # Modbus address space (0x0000-0xFFFF). @@ -107,10 +135,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), + cv.Optional(CONF_BITS): cv.ensure_list(ModbusServerBitSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), _validate_register_ranges, _validate_no_overlapping_registers, + _validate_unique_bit_addresses, ) @@ -152,7 +182,7 @@ async def to_code(config): await cg.process_lambda( server_register[CONF_READ_LAMBDA], [(cg.uint16, "address")], - return_type=cpp_type, + return_type=cg.optional.template(cpp_type), ), ) ) @@ -170,5 +200,27 @@ async def to_code(config): if server_register[CONF_ALLOW_PARTIAL_READ]: cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) + for server_bit in config.get(CONF_BITS, []): + server_bit_var = cg.new_Pvariable(server_bit[CONF_ID], server_bit[CONF_ADDRESS]) + cg.add( + server_bit_var.set_read_lambda( + await cg.process_lambda( + server_bit[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cg.optional.template(cg.bool_), + ) + ) + ) + if (write_lambda := server_bit.get(CONF_WRITE_LAMBDA)) is not None: + cg.add( + server_bit_var.set_write_lambda( + await cg.process_lambda( + write_lambda, + parameters=[(cg.uint16, "address"), (cg.bool_, "x")], + return_type=cg.bool_, + ) + ) + ) + cg.add(var.add_server_bit(server_bit_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f2a8c53f45..86366c7ce0 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,4 +5,5 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_BITS = "bits" CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e63495cb25..feb0e67725 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -33,6 +33,12 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); + // No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement + // the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers + // ILLEGAL_DATA_ADDRESS below. + if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled) + return ExceptionCode::ILLEGAL_FUNCTION; + const uint32_t end_address = static_cast(start_address) + number_of_registers; uint32_t current_address = start_address; while (current_address < end_address) { @@ -75,7 +81,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u return ExceptionCode::ILLEGAL_DATA_ADDRESS; } - int64_t value = server_register->read_lambda(); + const optional read_value = server_register->read_lambda(); + if (!read_value.has_value()) { + ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.", + server_register->address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + const int64_t value = *read_value; char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), @@ -106,6 +118,11 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); + // No registers configured (e.g. a bits-only server): this device does not implement the register-write + // function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS. + if (this->server_registers_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + auto for_each_register = [this, start_address, ®isters](const std::function &callback) -> bool { @@ -167,6 +184,83 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, return {}; } +ServerBit *ModbusServer::find_bit_(uint16_t address) const { + for (auto *server_bit : this->server_bits_) { + if (server_bit->address == address) { + return server_bit; + } + } + return nullptr; +} + +modbus::ResponseStatus ModbusServer::on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) { + ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.", + this->address_, start_address, bits.size()); + + // No bits configured: this device does not implement the coil/discrete-input function, so answer + // ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); // range pre-checked by the hub + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->read_lambda) { + ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + const optional value = server_bit->read_lambda(address); + if (!value.has_value()) { + ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + bits.set(i, *value); + } + return {}; +} + +modbus::ResponseStatus ModbusServer::on_write_coils(uint16_t start_address, modbus::PackedBits bits) { + ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_, + start_address, bits.size()); + + // No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather + // than ILLEGAL_DATA_ADDRESS. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + // Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write + // before discovering a problem (mirrors the register write's two passes). + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // bits this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + } + + // Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather + // than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the + // register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight + // can never turn this into a silent null dereference. The only expected failure is a write callback + // rejecting the value at runtime, which cannot be rolled back. + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + if (!server_bit->write_lambda(address, bits[i])) { + ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + } + return {}; +} + void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, "ModbusServer:\n" @@ -184,6 +278,11 @@ void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } + ESP_LOGCONFIG(TAG, "server bits"); + for (auto &b : this->server_bits_) { + ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false", + b->write_lambda ? "true" : "false"); + } #endif } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f6484d8e6b..22903abfad 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -20,7 +20,7 @@ struct ServerCourtesyResponse { }; class ServerRegister { - using ReadLambda = std::function; + using ReadLambda = std::function()>; using WriteLambda = std::function; public: @@ -30,13 +30,18 @@ class ServerRegister { this->register_count = register_count; } - template void set_read_lambda(const std::function &&user_read_lambda) { - this->read_lambda = [this, user_read_lambda]() -> int64_t { - T user_value = user_read_lambda(this->address); + /// The user lambda returns optional: an empty optional declines the read, answering the whole + /// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly. + template void set_read_lambda(const std::function(uint16_t address)> &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> optional { + const optional user_value = user_read_lambda(this->address); + if (!user_value.has_value()) { + return {}; + } if constexpr (std::is_same_v) { - return bit_cast(user_value); + return bit_cast(*user_value); } else { - return static_cast(user_value); + return static_cast(*user_value); } }; } @@ -97,17 +102,43 @@ class ServerRegister { WriteLambda write_lambda; }; +/// A single bit in the server's coil/discrete-input table. Coils (0x01/0x05/0x0F) and discrete +/// inputs (0x02) share one bit address space, mirroring how holding and input registers share the +/// register table: both read function codes are served from the same bits. +class ServerBit { + /// Returning an empty optional declines the read: the whole request is answered with a + /// SERVICE_DEVICE_FAILURE exception. `return true;`/`return false;` convert implicitly. + using ReadLambda = std::function(uint16_t address)>; + using WriteLambda = std::function; + + public: + explicit ServerBit(uint16_t address) : address(address) {} + void set_read_lambda(ReadLambda &&read_lambda) { this->read_lambda = std::move(read_lambda); } + void set_write_lambda(WriteLambda &&write_lambda) { this->write_lambda = std::move(write_lambda); } + + uint16_t address{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// Registers a server bit with the controller. Called by esphomes code generator + void add_server_bit(ServerBit *server_bit) { server_bits_.push_back(server_bit); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; + /// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are + /// served from the same bit table (see ServerBit) + modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final; + /// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors + modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; @@ -118,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { protected: /// Find the registered value whose register span contains address, or nullptr if none does. ServerRegister *find_containing_register_(uint32_t address) const; + /// Find the registered bit at address, or nullptr if none is. + ServerBit *find_bit_(uint16_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; + /// Collection of all server bits (coils/discrete inputs) for this component + std::vector server_bits_{}; /// Server courtesy response ServerCourtesyResponse server_courtesy_response_{ .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py index 3e041c6d4a..ce1098fbca 100644 --- a/tests/component_tests/modbus_server/test_modbus_server.py +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -7,8 +7,13 @@ from esphome.components.modbus_server import ( SERVER_SENSOR_VALUE_TYPE, _validate_no_overlapping_registers, _validate_register_ranges, + _validate_unique_bit_addresses, +) +from esphome.components.modbus_server.const import ( + CONF_BITS, + CONF_REGISTERS, + CONF_VALUE_TYPE, ) -from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE from esphome.const import CONF_ADDRESS @@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict: } +def _bits_config(addresses: list[int]) -> dict: + return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]} + + def test_non_overlapping_registers_pass() -> None: # Values that tile the address space without gaps or overlaps are accepted. config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) @@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None: _validate_no_overlapping_registers(config) +def test_unique_bit_addresses_pass() -> None: + config = _bits_config([0x00, 0x01, 0x02]) + assert _validate_unique_bit_addresses(config) is config + + +def test_duplicate_bit_address_rejected() -> None: + # Coils and discrete inputs share one bit address space, so a repeated address is rejected. + config = _bits_config([0x05, 0x05]) + with pytest.raises(cv.Invalid, match="more than once"): + _validate_unique_bit_addresses(config) + + def test_multi_register_value_overlapping_neighbour_rejected() -> None: # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 1f3a8f551b..3f84a3f6da 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -15,6 +15,16 @@ modbus_server: - id: modbus_server3 address: 0x3 modbus_id: mod_bus2 + bits: + - address: 0x0 + read_lambda: |- + return true; + - address: 0x1 + read_lambda: |- + return address == 0x1; + write_lambda: |- + printf("bit address=%d, value=%d\n", (int) address, (int) x); + return true; registers: - address: 0x9 value_type: S_DWORD diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 2137a77f3d..ce39e83736 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } -// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +// A write to an address not covered by any configured register (on a populated server) yields +// ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [](int64_t) { return true; }; + server.add_server_register(®); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION. +TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + // A write_lambda failing at runtime is the one non-atomic case: the earlier register is already // applied, and the handler reports SERVICE_DEVICE_FAILURE. TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { @@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { EXPECT_EQ(out[1], 0xABCD); } -// An unregistered address with courtesy disabled is rejected. +// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS. TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + RegisterValues out; auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); @@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured (courtesy disabled) does not implement the register-read +// function: ILLEGAL_FUNCTION. +TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + RegisterValues out; + auto status = server.on_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + +// A register read lambda returning an empty optional declines the read: the whole request is +// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda so the optional-forwarding wrapper +// (not a hand-assigned read_lambda) is what carries the decline through. +TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.set_read_lambda([](uint16_t address) -> optional { return {}; }); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + // --- partial reads (opt-in) ---------------------------------------------------- // With allow_partial_read, reading only the first register of a DWORD returns its high word. @@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) { EXPECT_EQ(second[0], 0x1234); } +// --- bits (coils / discrete inputs, one shared address space) ------------------- + +// Bits are read through the shared table regardless of which read function code arrived: +// the hub routes both 0x01 and 0x02 to on_read_bits(). +TEST(ModbusServerBits, ReadSetsRequestedBits) { + ModbusServer server; + ServerBit bit0(0x0000); + bit0.set_read_lambda([](uint16_t) { return true; }); + ServerBit bit1(0x0001); + bit1.set_read_lambda([](uint16_t) { return false; }); + ServerBit bit2(0x0002); + bit2.set_read_lambda([](uint16_t) { return true; }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + server.add_server_bit(&bit2); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0b101); +} + +// The read lambda receives the bit's address, so one lambda can serve several bits. +TEST(ModbusServerBits, ReadLambdaReceivesAddress) { + ModbusServer server; + ServerBit server_bit(0x0007); + server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; }); + server.add_server_bit(&server_bit); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0x01); +} + +// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerBits, UnreadableBitRejectsRead) { + ModbusServer server; + ServerBit readable(0x0000); + readable.set_read_lambda([](uint16_t) { return true; }); + ServerBit write_only(0x0001); + write_only.set_write_lambda([](uint16_t, bool) { return true; }); + server.add_server_bit(&readable); + server.add_server_bit(&write_only); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1)); + EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A read lambda returning an empty optional declines the read: the whole request is answered +// with SERVICE_DEVICE_FAILURE. +TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerBit ok(0x0000); + ok.set_read_lambda([](uint16_t) { return true; }); + ServerBit declining(0x0001); + declining.set_read_lambda([](uint16_t) -> optional { return {}; }); + server.add_server_bit(&ok); + server.add_server_bit(&declining); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + +// A multi-coil write applies every bit and reports success. +TEST(ModbusServerBits, WriteAppliesAllBits) { + ModbusServer server; + bool state[2] = {false, true}; + ServerBit bit0(0x0000); + bit0.set_write_lambda([&state](uint16_t, bool value) { + state[0] = value; + return true; + }); + ServerBit bit1(0x0001); + bit1.set_write_lambda([&state](uint16_t, bool value) { + state[1] = value; + return true; + }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + + const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_FALSE(status.has_value()); + EXPECT_TRUE(state[0]); + EXPECT_FALSE(state[1]); +} + +// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any +// bit is applied. +TEST(ModbusServerBits, UnwritableBitAppliesNothing) { + ModbusServer server; + bool written = false; + ServerBit writable(0x0000); + writable.set_write_lambda([&written](uint16_t, bool) { + written = true; + return true; + }); + ServerBit read_only(0x0001); + read_only.set_read_lambda([](uint16_t) { return false; }); + server.add_server_bit(&writable); + server.add_server_bit(&read_only); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(written); // the writable bit must NOT have been applied +} + +// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and +// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior). +TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerBit first(0x0000); + first.set_write_lambda([&first_written](uint16_t, bool) { + first_written = true; + return true; + }); + ServerBit second(0x0001); + second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime + server.add_server_bit(&first); + server.add_server_bit(&second); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); +} + } // namespace esphome::modbus_server diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml index 167ad2c5bb..e445093625 100644 --- a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -133,8 +133,8 @@ button: on_error: then: - lambda: "id(error_code).publish_state((int) exception_code);" - # The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read - # action's request PDU and its typed error delivery. + # The mock server maps no bits, so it does not implement the coil function: a coil read draws + # ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery. - modbus_client.read_coils: address: 1 start_address: 0x00 @@ -166,7 +166,7 @@ button: on_not_sent: then: - lambda: "id(not_sent_flag).publish_state(1);" - # Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION. + # Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION. - modbus_client.write_multiple_coils: address: 1 start_address: 0x00 diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml new file mode 100644 index 0000000000..cb6fc6f074 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml @@ -0,0 +1,147 @@ +esphome: + name: uart-mock-modbus-srv-bits + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; + +# The same four bits are read both as coils (FC 0x01) and as discrete inputs +# (FC 0x02): the server serves both from one shared bit table, so the two +# views must always agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 65af57b944..4d31644559 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -387,8 +387,9 @@ class SensorStateCollector: class SensorTracker: """Data-driven sensor state tracker with expected-value futures. - Tracks sensor state updates and resolves futures when sensors report - specific expected values. Eliminates per-sensor future boilerplate. + Tracks sensor and binary sensor state updates and resolves futures when + they report specific expected values. Eliminates per-sensor future + boilerplate. Usage:: @@ -421,7 +422,10 @@ class SensorTracker: def on_state(self, state: EntityState) -> None: """State callback suitable for ``subscribe_states``.""" - if not isinstance(state, SensorState) or state.missing_state: + if ( + not isinstance(state, (SensorState, BinarySensorState)) + or state.missing_state + ): return sensor_name = self.key_to_sensor.get(state.key) if not sensor_name or sensor_name not in self.sensor_states: diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 1994d02c34..17ab21f873 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -21,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, NumberInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest from .state_utils import SensorTracker, find_entity @@ -411,6 +411,68 @@ async def test_uart_mock_modbus_server_controller_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_bits( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test coil/discrete-input round trips between controller and server bits. + + The server serves four bits from one shared table. The controller reads + each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02), + so the two views must always agree. Two bits are then written back, one + via the single-coil write (FC 0x05) and one via the multiple-coils write + (FC 0x0F), and the new values must show up in both read views. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + initial_values = { + "bit_coil_0": True, + "bit_coil_1": False, + "bit_coil_2": False, + "bit_coil_3": True, + "bit_di_0": True, + "bit_di_1": False, + "bit_di_2": False, + "bit_di_3": True, + } + tracker = SensorTracker(list(initial_values.keys())) + + # Phase 1: expect initial baseline values in both read views + initial_futures = tracker.expect_all(initial_values) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + { + "bit_coil_2": True, + "bit_di_2": True, + "bit_coil_3": False, + "bit_di_3": False, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Flip both writable bits: 0x02 false -> true, 0x03 true -> false + for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): + entity = find_entity(entities, switch_name, SwitchInfo) + assert entity is not None, f"{switch_name} switch entity not found" + client.switch_command(entity.key, value) + + # Wait for both read views to reflect the written values + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -447,10 +509,11 @@ async def test_uart_mock_modbus_client_typed( with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); a read of unserved register 0x99 resolves via on_error with the device's exception code (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via - on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error - delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 - chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from - the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not + implement the coil function - proving the bit-read request and typed error delivery. A multi-register + write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler + (-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server + (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent (-> not_sent_flag). """ From 007c677da13723a81cc4084ee783631756c72e15 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 11 Aug 2026 01:58:34 +0200 Subject: [PATCH 077/597] [mitsubishi_cn105] Refactor property encoding/decoding (#16709) Co-authored-by: J. Nick Koston --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 246 ++------------ .../mitsubishi_cn105/mitsubishi_cn105.h | 31 +- .../mitsubishi_cn105_properties.h | 302 ++++++++++++++++++ .../climate/mitsubishi_cn105_tests.cpp | 42 +-- tests/components/mitsubishi_cn105/common.h | 5 +- 5 files changed, 374 insertions(+), 252 deletions(-) create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 415de34166..6683a9a25b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -4,6 +4,7 @@ #include #include #include +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -11,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver"; static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; - static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; static constexpr uint8_t PREAMBLE = 0xFC; @@ -31,86 +30,6 @@ static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -template struct LookupMap { - using value_type = decltype(Unknown); - static constexpr auto UNKNOWN_VALUE = Unknown; - const std::array table; - - constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } - - constexpr bool reverse_lookup(value_type value, uint8_t &out) const { - static_assert(N <= std::numeric_limits::max()); - if (value == UNKNOWN_VALUE) { - return false; - } - for (uint8_t i = 0; i < static_cast(N); ++i) { - if (this->table[i] == value) { - out = i; - return true; - } - } - return false; - } - - constexpr bool is_valid(value_type value) const { - uint8_t raw; - return reverse_lookup(value, raw); - } -}; - -template static constexpr auto make_map(const T (&values)[N]) { - return LookupMap{std::to_array(values)}; -} - -static constexpr auto PROTOCOL_MODE_MAP = make_map({ - MitsubishiCN105::Mode::UNKNOWN, // 0x00 - MitsubishiCN105::Mode::HEAT, // 0x01 - MitsubishiCN105::Mode::DRY, // 0x02 - MitsubishiCN105::Mode::COOL, // 0x03 - MitsubishiCN105::Mode::UNKNOWN, // 0x04 - MitsubishiCN105::Mode::UNKNOWN, // 0x05 - MitsubishiCN105::Mode::UNKNOWN, // 0x06 - MitsubishiCN105::Mode::FAN_ONLY, // 0x07 - MitsubishiCN105::Mode::AUTO // 0x08 -}); - -static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ - MitsubishiCN105::FanMode::AUTO, // 0x00 - MitsubishiCN105::FanMode::QUIET, // 0x01 - MitsubishiCN105::FanMode::SPEED_1, // 0x02 - MitsubishiCN105::FanMode::SPEED_2, // 0x03 - MitsubishiCN105::FanMode::UNKNOWN, // 0x04 - MitsubishiCN105::FanMode::SPEED_3, // 0x05 - MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}); - -static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map({ - MitsubishiCN105::VaneMode::AUTO, // 0x00 - MitsubishiCN105::VaneMode::POSITION_1, // 0x01 - MitsubishiCN105::VaneMode::POSITION_2, // 0x02 - MitsubishiCN105::VaneMode::POSITION_3, // 0x03 - MitsubishiCN105::VaneMode::POSITION_4, // 0x04 - MitsubishiCN105::VaneMode::POSITION_5, // 0x05 - MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::VaneMode::SWING // 0x07 -}); - -static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map({ - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 - MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 - MitsubishiCN105::WideVaneMode::LEFT, // 0x02 - MitsubishiCN105::WideVaneMode::CENTER, // 0x03 - MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 - MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 - MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B - MitsubishiCN105::WideVaneMode::SWING // 0x0C -}); - static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -124,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } @@ -277,14 +192,14 @@ bool MitsubishiCN105::should_request_telemetry_() const { return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } -void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - FrameParser::dump_buffer_vv("TX", packet, len); - this->device_.write_array(packet, len); +void MitsubishiCN105::send_packet_(std::span packet) { + FrameParser::dump_buffer_vv("TX", packet.data(), packet.size()); + this->device_.write_array(packet.data(), packet.size()); this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { - std::array payload = {this->current_status_msg_type_}; + std::array payload{this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } @@ -336,12 +251,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: - return this->parse_status_settings_(payload, len); + if (!decoder.decode_settings(this->status_)) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + return true; case STATUS_MSG_TELEMETRY: - return this->parse_status_telemetry_(payload, len); + if (!decoder.decode_room_temperature(this->status_)) { + ESP_LOGVV(TAG, "RX telemetry payload too short"); + return false; + } + this->last_telemetry_update_ms_ = get_loop_time_ms(); + return true; default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -349,54 +274,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay } } -bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { - if (len <= 10) { - ESP_LOGVV(TAG, "RX settings payload too short"); - return false; - } - - if (!this->pending_updates_.contains(UpdateFlag::POWER)) { - this->status_.power_on = payload[2] != 0; - } - - this->use_temperature_encoding_b_ = payload[10] != 0; - if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); - } - - if (!this->pending_updates_.contains(UpdateFlag::MODE)) { - const bool i_see = payload[3] > 0x08; - this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); - } - - if (!this->pending_updates_.contains(UpdateFlag::VANE)) { - this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); - } - - this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; - if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { - this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); - } - - return true; -} - -bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) { - if (len <= 5) { - ESP_LOGVV(TAG, "RX telemetry payload too short"); - return false; - } - - this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_telemetry_update_ms_ = get_loop_time_ms(); - - return true; -} - void MitsubishiCN105::set_remote_temperature(float temperature) { if (std::isnan(temperature)) { ESP_LOGD(TAG, "Ignoring NaN remote temperature"); @@ -415,12 +292,12 @@ void MitsubishiCN105::clear_remote_temperature() { void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) { this->remote_temperature_half_deg_ = temperature_half_deg; - this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Remote::ID); } void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; - this->pending_updates_.set(UpdateFlag::POWER); + this->pending_updates_.set(Property::Power::ID); } void MitsubishiCN105::set_target_temperature(float target_temperature) { @@ -429,101 +306,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { return; } this->status_.target_temperature = target_temperature; - this->pending_updates_.set(UpdateFlag::TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Target::ID); } void MitsubishiCN105::set_mode(Mode mode) { - if (!PROTOCOL_MODE_MAP.is_valid(mode)) { - ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); - return; + if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } - this->status_.mode = mode; - this->pending_updates_.set(UpdateFlag::MODE); } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { - ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); - return; + if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast(fan_mode)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { - if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { - ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); - return; + if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast(vane_mode)); } - this->status_.vane_mode = vane_mode; - this->pending_updates_.set(UpdateFlag::VANE); } void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { - if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { - ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); - return; + if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast(wide_vane_mode)); } - this->status_.wide_vane_mode = wide_vane_mode; - this->pending_updates_.set(UpdateFlag::WIDE_VANE); } void MitsubishiCN105::apply_settings_() { std::array payload{}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; // Apply all other pending settings first; handle REMOTE_TEMPERATURE last - if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) { - payload[0] = 0x07; - if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) { - payload[3] = 0x80; - } else { - payload[1] = 0x01; - payload[2] = static_cast(this->remote_temperature_half_deg_ - 16); - payload[3] = static_cast(this->remote_temperature_half_deg_ + 128); - } - this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE); + if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { + encoder.encode_remote_temperature(this->remote_temperature_half_deg_); } else { - payload[0] = 0x01; - if (this->pending_updates_.contains(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); - } else { - payload[5] = - static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); - } - } - - if (this->pending_updates_.contains(UpdateFlag::MODE) && - PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - if (this->pending_updates_.contains(UpdateFlag::VANE) && - PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { - payload[1] |= 0x10; - } - - if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && - PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { - payload[2] |= 0x01; - if (this->set_wide_vane_high_bit_) { - payload[13] |= 0x80; - } - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, - UpdateFlag::VANE, UpdateFlag::WIDE_VANE); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 3169359290..b6b11b4820 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -5,6 +5,7 @@ #include #include +#include namespace esphome::mitsubishi_cn105 { @@ -121,44 +122,47 @@ class MitsubishiCN105 { uint8_t read_pos_{0}; }; - enum class UpdateFlag : uint8_t { + enum class PropertyId : uint8_t { TEMPERATURE = 0, POWER = 1, MODE = 2, FAN = 3, VANE = 4, WIDE_VANE = 5, - REMOTE_TEMPERATURE = 6, + REMOTE_TEMPERATURE = 6 }; struct UpdateFlags { - template void set(Flags... flags) { (this->mask_.insert(flags), ...); } - template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + void set(PropertyId id) { this->mask_.insert(id); } + void clear(PropertyId id) { this->mask_.erase(id); } bool any() const { return !this->mask_.empty(); } - bool contains(UpdateFlag flag) const { return this->mask_.count(flag); } - bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); } + bool contains(PropertyId id) const { return this->mask_.count(id); } + bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); } protected: using Mask = - FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; - + FiniteSetMask(PropertyId::REMOTE_TEMPERATURE) + 1>>; Mask mask_; }; + struct PropertyContext { + bool use_temperature_encoding_b{false}; + bool set_wide_vane_high_bit{false}; + }; + + friend struct Property; + void set_state_(State new_state); void did_transition_(State to); bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); - bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_telemetry_(const uint8_t *payload, size_t len); - void send_packet_(const uint8_t *packet, size_t len); + void send_packet_(std::span packet); void update_status_(); bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); - template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } static bool should_transition(State from, State to); static const LogString *state_to_string(State state); @@ -175,8 +179,7 @@ class MitsubishiCN105 { Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; - bool use_temperature_encoding_b_{false}; - bool set_wide_vane_high_bit_{false}; + PropertyContext property_context_; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h new file mode 100644 index 0000000000..1f5faf61af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +template struct LookupMap { + using value_type = decltype(Unknown); + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == Unknown) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +struct Property { + using PropertyId = MitsubishiCN105::PropertyId; + using Status = MitsubishiCN105::Status; + using PropertyContext = MitsubishiCN105::PropertyContext; + + struct Power { + static constexpr auto ID = PropertyId::POWER; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.power_on = payload[2] != 0; + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x01; + payload[3] = status.power_on ? 0x01 : 0x00; + } + }; + + struct Temperature { + struct Target { + static constexpr auto ID = PropertyId::TEMPERATURE; + static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.use_temperature_encoding_b = payload[10] != 0; + } + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x04; + if (ctx.use_temperature_encoding_b) { + payload[14] = static_cast(std::round(status.target_temperature * 2.0f) + 128); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature)); + } + } + }; + + struct Room { + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.room_temperature = Temperature::decode(payload[2], payload[5], 10); + } + }; + + struct Remote { + static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE; + + static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) { + if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) { + payload[3] = 0x80; + } else { + payload[1] = 0x01; + payload[2] = static_cast(remote_temperature_half_deg - 16); + payload[3] = static_cast(remote_temperature_half_deg + 128); + } + } + }; + + protected: + static constexpr float decode(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; + } + }; + + template struct Lookup { + using Value = std::remove_cvref_t().*Field)>; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx)); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + uint8_t raw; + if (Derived::MAP.reverse_lookup(status.*Field, raw)) { + Derived::encode_raw(payload, raw, ctx); + } + } + + template static bool validate_and_set(Value value, Status &status, Mask &mask) { + uint8_t raw; + if (!Derived::MAP.reverse_lookup(value, raw)) { + return false; + } + status.*Field = value; + mask.set(Derived::ID); + return true; + } + + private: + friend Derived; + constexpr Lookup() = default; + }; + + struct Mode : Lookup { + static constexpr auto ID = PropertyId::MODE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { + const bool i_see = payload[3] > 0x08; + return payload[3] - (i_see ? 0x08 : 0); + } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x02; + payload[4] = raw; + } + }; + + struct FanMode : Lookup { + static constexpr auto ID = PropertyId::FAN; + static constexpr auto MAP = make_map({ + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x08; + payload[6] = raw; + } + }; + + struct VaneMode : Lookup { + static constexpr auto ID = PropertyId::VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x10; + payload[7] = raw; + } + }; + + struct WideVaneMode : Lookup { + static constexpr auto ID = PropertyId::WIDE_VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C + }); + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80; + } + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) { + payload[2] |= 0x01; + payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw; + } + }; + + template struct Decoder { + const std::span payload; + PropertyContext &context; + const Mask &pending_writes; + + bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) { + if (this->payload.size() <= 10) { + return false; + } + this->decode_(status); + return true; + } + + bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) { + if (this->payload.size() <= 5) { + return false; + } + this->decode_(status); + return true; + } + + protected: + template ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) { + T::decode_context(this->context, this->payload.data()); + if constexpr (requires { T::ID; }) { + if (this->pending_writes.contains(T::ID)) { + return; + } + } + T::decode(out, this->payload.data(), this->context); + } + + template void ESPHOME_ALWAYS_INLINE decode_(Out &out) { + (this->decode_one_(out), ...); + } + }; + + template struct Encoder { + uint8_t *payload; + const PropertyContext &context; + Mask &pending_writes; + + void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) { + this->payload[0] = 0x01; + this->encode_and_clear_(status); + } + + void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) { + this->payload[0] = 0x07; + this->encode_and_clear_(remote_temperature_half_deg); + } + + protected: + template void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) { + (this->encode_one_(in), ...); + (this->pending_writes.clear(T::ID), ...); + } + + template void encode_one_(const In &in) { + if (this->pending_writes.contains(T::ID)) { + T::encode(this->payload, in, this->context); + } + } + }; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 28fdfbb313..3bc6d5b2b8 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -266,7 +266,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { ctx.sut.update(); EXPECT_TRUE(ctx.sut.status().power_on); - EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_); + EXPECT_FALSE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET); @@ -281,7 +281,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { ctx.sut.update(); EXPECT_FALSE(ctx.sut.status().power_on); - EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_); + EXPECT_TRUE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4); @@ -316,7 +316,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_FALSE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { @@ -328,7 +328,7 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_TRUE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { @@ -354,7 +354,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.0f); ctx.sut.apply_settings(); @@ -365,7 +365,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.5f); ctx.sut.apply_settings(); @@ -416,7 +416,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { MitsubishiCN105TestsContext ctx; - ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.property_context_.set_wide_vane_high_bit = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -445,7 +445,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -508,7 +508,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -521,11 +521,11 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::POWER)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::MODE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::FAN)); // ACK the first write. Remote temperature should still be pending afterward. ctx.uart.tx.clear(); @@ -533,7 +533,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); ASSERT_FALSE(ctx.sut.update()); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); // The next apply sends the remote-temperature packet and clears the last pending flag. ctx.uart.tx.clear(); @@ -557,7 +557,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -581,25 +581,25 @@ TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(40.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(NAN); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index a14043c737..b90ddf3995 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -47,12 +47,11 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; using MitsubishiCN105::State; - using MitsubishiCN105::UpdateFlag; + using MitsubishiCN105::PropertyId; using MitsubishiCN105::state_; using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; - using MitsubishiCN105::use_temperature_encoding_b_; - using MitsubishiCN105::set_wide_vane_high_bit_; + using MitsubishiCN105::property_context_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; From 791d6659c03ce13b19561a6917d2d53a257ac8f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:43:35 +0000 Subject: [PATCH 078/597] Bump platformdirs from 4.11.0 to 4.11.1 (#18250) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 98c2f47d7e..92db51db7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.0 # native esp-idf toolchain global cache dir +platformdirs==4.11.1 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From 3de01d2a2eb414ea7775594a4a9a6c71e20859b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 19:56:35 -0500 Subject: [PATCH 079/597] Bump aioesphomeapi to 45.8.0 (#18251) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92db51db7d..a90d9ec9eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.7.0 +aioesphomeapi==45.8.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From c2ba2fbfc4d4862f5dba26bc6e402ee5ba0b5a31 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:18:09 +1200 Subject: [PATCH 080/597] [ld24xx] Use MAC address size constants instead of literals (#18253) --- esphome/components/ld2410/ld2410.cpp | 5 +++-- esphome/components/ld2410/ld2410.h | 2 +- esphome/components/ld2412/ld2412.cpp | 4 ++-- esphome/components/ld2412/ld2412.h | 2 +- esphome/components/ld2450/ld2450.cpp | 4 ++-- esphome/components/ld2450/ld2450.h | 2 +- esphome/components/ld24xx/ld24xx.h | 3 +-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f..914de8e145 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -8,6 +8,7 @@ #endif #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::ld2410 { @@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index a0cce36d16..061846f1f1 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; #ifdef USE_NUMBER diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dc..7041b7539f 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index f722f938ae..a52402c2ea 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 0dc2638aad..4b41d63a88 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 10f9bb874a..c4f06ad224 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t moving_presence_millis_ = 0; uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t zone_type_ = 0; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index cba1b68a15..deac04e86f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; // Helper function to format MAC address with stack allocation // Returns pointer to UNKNOWN_MAC constant or formatted buffer -// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) -inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { if (mac_address_is_valid(mac_address)) { format_mac_addr_upper(mac_address, buffer.data()); return buffer.data(); From eab9a47aa2010d18332c15daec709fb1152409c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 21:20:39 -0500 Subject: [PATCH 081/597] [bluetooth_proxy] Retry dropped services-done, disconnect and scanner-state notifications (#18225) --- .../bluetooth_connection.h | 10 + .../bluetooth_connection_bluedroid.cpp | 10 +- .../bluetooth_connection_hub.cpp | 30 ++- .../bluetooth_connection_hub.h | 24 ++- .../bluetooth_proxy/bluetooth_proxy.cpp | 181 ++++++++++++++---- .../bluetooth_proxy/bluetooth_proxy.h | 65 ++++++- 6 files changed, 262 insertions(+), 58 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 5052e7eca1..53e319e369 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -82,6 +82,16 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed +// Every sentinel must stay below the >= 0 streaming gate and clear of +// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused. +static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0); +static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED && + SERVICES_DONE_PENDING != GATT_NOT_CONNECTED); +// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done +// delivered near the client's 30 s timeout could land on a fresh request's +// empty accumulator and cache as an empty database. +static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; // ---- Service-streaming size budget, shared by every platform's streamer ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index f24d261c57..d6b815fc2e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -407,20 +407,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { return; } if (conn.send_service_ >= this->service_total_) { - conn.send_service_ = DONE_SENDING_SERVICES; - conn.proxy_->send_gatt_services_done(conn.address_); this->release_services(); + conn.send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list). + // The subscriber vanished mid-stream. auto *api_conn = conn.proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); - conn.send_service_ = DONE_SENDING_SERVICES; - this->release_services(); + conn.park_service_stream_(); return; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index b913bb9a55..f79669dc32 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -299,25 +299,39 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, // ---- Service streaming ---- +void BluetoothConnection::send_services_done_() { + if (this->proxy_->send_gatt_services_done(this->address_)) { + // Sent, or subscriber gone (park silently; its timeout arbitrates). + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (this->send_service_ != SERVICES_DONE_PENDING) { + // Warn on the transition only; retries stay silent. + ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); + this->services_done_retries_ = 0; + this->send_service_ = SERVICES_DONE_PENDING; + } else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + } +} + void BluetoothConnection::send_service_for_discovery_() { auto table = this->backend_->get_service_table(); if (this->send_service_ >= table.service_count) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); this->backend_->release_services(); + this->send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list) and free the table; the - // api-gone sweep tears the connection down anyway. + // The subscriber vanished mid-stream; the api-gone sweep tears the + // connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, this->address_str_); - this->send_service_ = DONE_SENDING_SERVICES; - this->backend_->release_services(); + this->park_service_stream_(); return; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 82d9ae7db4..783a8c466b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -126,7 +126,23 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->send_service_for_discovery_(); } } + /// Park the stream without services-done and free any held table: an + /// interrupted stream must never be declared complete (the client's + /// timeout arbitrates), and an owed done is dropped with it. + void park_service_stream_() { + if (this->send_service_ >= 0) { + this->backend_->release_services(); + this->send_service_ = DONE_SENDING_SERVICES; + } else if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_service_ = DONE_SENDING_SERVICES; + } + } void send_service_for_discovery_(); + /// Send services-done and settle the cursor: DONE when it lands (or no + /// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain + /// retries). Callers release the table first; the message needs only the + /// address. + void send_services_done_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -151,10 +167,14 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); + // Ordered so neither byte's fields straddle a storage unit: 3+5 and + // 4+2+1+1 fill the two tail bytes exactly. ClientState state_ : 3 {ClientState::IDLE}; - bool paired_ : 1 {false}; - ConnectionType connection_type_ : 2 {ConnectionType::V1}; + static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); + uint8_t services_done_retries_ : 5 {0}; uint8_t connection_index_ : 4 {0}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + bool paired_ : 1 {false}; bool services_discovered_ : 1 {false}; }; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index af52a25ec0..6b49b28cd6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -40,7 +40,7 @@ static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { if (this->api_connection_ == nullptr) - return false; + return true; // Nobody subscribed: nothing owed api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -51,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK +void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) { + // False only on a refused frame, so the latch arms only when a retry is owed. + this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state); +} +#else void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -62,7 +67,7 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_BLE_SCANNER_STATE_CALLBACK +#endif // USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -78,7 +83,7 @@ void BluetoothProxy::setup() { #ifdef USE_BLE_SCANNER_STATE_CALLBACK // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { - static_cast(self)->send_bluetooth_scanner_state_(state); + static_cast(self)->send_scanner_state_(state); }}); #endif } @@ -190,8 +195,48 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); } +void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { + // Match before free entry so one address never occupies two pool slots. + PendingDisconnect *free_entry = nullptr; + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.matches(address)) { + owed.set(address, error); + return; + } + if (free_entry == nullptr && owed.empty()) { + free_entry = &owed; + } + } + if (free_entry != nullptr) { + free_entry->set(address, error); + return; + } + // Every entry is owed: evict the first so the newest loss is not silent too. + ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full", + (unsigned long long) this->pending_disconnections_[0].address()); + this->pending_disconnections_[0].set(address, error); +} + +void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { + // A reconnect supersedes the owed disconnect; a late resend would shadow + // the new connection. + for (uint8_t i = 0; i < this->connection_count_; i++) { + if (this->pending_disconnections_[i].matches(address)) { + this->pending_disconnections_[i].clear(); + } + } +} + void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { - this->send_device_connection(connection->get_address(), false, 0, reason); + if (!this->send_device_connection(connection->get_address(), false, 0, reason)) { + // The client has no other way to learn of an unsolicited disconnect; + // latch and let loop()'s paced drain deliver it. V by design: a louder + // level would ride the same congested link this reports on. + ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(), + connection->address_str()); + this->latch_pending_disconnection_(connection->get_address(), reason); + } connection->set_address(0); connection->send_service_ = INIT_SENDING_SERVICES; this->send_connections_free(); @@ -206,14 +251,20 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); - if (conn_addr == address) + if (conn_addr == address) { + // A connect request supersedes an owed disconnect. + if (reserve) { + this->clear_pending_disconnection_(address); + } return connection; + } if (free_slot == nullptr && conn_addr == 0) free_slot = connection; } if (!reserve || free_slot == nullptr) return nullptr; + this->clear_pending_disconnection_(address); free_slot->send_service_ = INIT_SENDING_SERVICES; free_slot->set_address(address); // All connections must start at INIT @@ -387,7 +438,30 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer } if (!connection->has_gatt_services()) { ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); - this->send_gatt_services_done(msg.address); + // Through the retrying sender: a drop must not leave discovery hanging. + // Re-entry does not depend on the cursor - this branch is gated on + // has_gatt_services() alone, so no restore is needed. + connection->send_services_done_(); + return; + } + if (connection->send_service_ > 0) { + // A request mid-stream restarts from the top so the requester always + // gets the full list. No duplicate risk: the client accumulates batches + // per request, and a same-session re-request only happens after the + // previous request timed out and discarded its partial list. + ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(), + connection->address_str()); + connection->send_service_ = 0; + return; + } + if (connection->send_service_ == SERVICES_DONE_PENDING) { + // A new request supersedes an owed done: the client accumulates batches + // per request, so its fresh, empty accumulator plus a bare done would + // cache as an empty database. The table is freed; the client's timeout + // arbitrates. + ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry", + connection->get_connection_index(), connection->address_str()); + connection->send_service_ = DONE_SENDING_SERVICES; return; } if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet @@ -515,7 +589,27 @@ void BluetoothProxy::loop() { return; } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Paced retries of owed per-slot notifications; subscriber swaps clear + // stale latches before this runs. + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->send_service_ == SERVICES_DONE_PENDING) { + connection->send_services_done_(); + } + auto &owed = this->pending_disconnections_[i]; + if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { + owed.clear(); + } + } +#endif + +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Resend a dropped scanner-state push (see scanner_state_pending_). + if (this->scanner_state_pending_) { + this->send_scanner_state_(this->hub_->get_scanner_state()); + } +#else // This hub doesn't push scanner-state transitions; poll and report on // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -601,24 +695,35 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - // A previous subscriber still holds the slot. This is almost always a stale - // connection from a client that dropped without a clean disconnect and has - // not yet hit the keepalive timeout; rejecting the new subscriber would - // silently starve it of advertisements until it reconnects, so the newest - // subscriber wins instead. - char old_peername[socket::SOCKADDR_STR_LEN]; - char new_peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), - api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), - this->api_connection_->get_peername_to(old_peername)); + if (api_connection != this->api_connection_) { + if (this->api_connection_ != nullptr) { + // A previous subscriber still holds the slot. This is almost always a + // stale connection from a client that dropped without a clean disconnect + // and has not yet hit the keepalive timeout; rejecting the new + // subscriber would silently starve it of advertisements until it + // reconnects, so the newest subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); + } + // Stale retry latches belong to the previous subscriber's session; a + // re-subscribe by the current one keeps what it is still owed. + this->connections_free_pending_ = false; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the new + // session; silence (the client's timeout) arbitrates. + this->connections_[i]->park_service_stream_(); + } + this->pending_disconnections_.fill({}); +#endif } - // A stale retry latch belongs to the previous subscriber's session. - this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). - this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); + this->send_scanner_state_(this->hub_->get_scanner_state()); #else this->send_polled_scanner_state_(); #endif @@ -631,20 +736,11 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti } this->api_connection_ = nullptr; this->connections_free_pending_ = false; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + this->scanner_state_pending_ = false; +#endif } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { - if (this->api_connection_ == nullptr) - return; - api::BluetoothDeviceConnectionResponse call; - call.address = address; - call.connected = connected; - call.mtu = mtu; - call.error = error; - // Fire and forget: a drop is covered by the client's own timeouts and the - // retried connections-free state. - this->api_connection_->send_message(call); -} void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -661,12 +757,23 @@ void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { } } -void BluetoothProxy::send_gatt_services_done(uint64_t address) { +bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing owed + api::BluetoothDeviceConnectionResponse call; + call.address = address; + call.connected = connected; + call.mtu = mtu; + call.error = error; + return this->api_connection_->send_message(call); +} + +bool BluetoothProxy::send_gatt_services_done(uint64_t address) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTGetServicesDoneResponse call; call.address = address; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d3e3144831..725429df24 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -24,7 +24,9 @@ namespace esphome::bluetooth_proxy { using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES; +using bluetooth_connection::SERVICES_DONE_PENDING; #ifdef BLUETOOTH_CONNECTION_HAS_GATT using BluetoothConnection = bluetooth_connection::BluetoothConnection; @@ -57,6 +59,43 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +/// One owed freed-slot connected=false notification in a single word: the +/// 48-bit address in the low bits, the sign-extending 16-bit reason on top. +/// Every reason that reaches the pool (esp_gatt_status_t, +/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t. +class PendingDisconnect { + public: + constexpr void set(uint64_t address, conn_err_t error) { + // Mask: the address originates from the client, and a stray high bit + // must not corrupt the reason. + this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); + } + constexpr void clear() { this->word_ = 0; } + // Whole-word test: set() is only ever given a live (nonzero) address. + constexpr bool empty() const { return this->word_ == 0; } + // Masked like set(), so a stray high bit cannot defeat the pool lookups. + constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } + constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; } + constexpr conn_err_t error() const { return static_cast(this->word_ >> 48); } + + private: + static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL; + uint64_t word_{0}; +}; +// Pin the packing at compile time: mask and sign round-trip for every +// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). +constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingDisconnect p; + p.set(address, error); + return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); +} +static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingDisconnect{}.empty()); +#endif + class BluetoothProxy final : public Component { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Allow the connection to update connections_free_response_ @@ -97,10 +136,14 @@ class BluetoothProxy final : public Component { return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); + /// False only when a subscriber refused the frame; true = delivered or + /// nobody subscribed. Request-answer callers ignore the result (client + /// timeouts cover those); only reset_connection_slot_ latches for retry. + bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); - void send_gatt_services_done(uint64_t address); + /// Same convention as send_device_connection: false only on a refused frame. + bool send_gatt_services_done(uint64_t address); void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); @@ -172,7 +215,9 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void send_scanner_state_(ble_device_base::ScannerState state); +#else void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -231,6 +276,10 @@ class BluetoothProxy final : public Component { /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service /// discovery and retry, rather than being told a partial list is complete. void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); + /// Drop any owed freed-slot notification for this address (client reconnected). + void clear_pending_disconnection_(uint64_t address); + /// Pool a refused freed-slot notification for the paced drain. + void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif // Memory optimized layout for 32-bit systems @@ -240,6 +289,10 @@ class BluetoothProxy final : public Component { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Group 2: Fixed-size array of connection pointers std::array connections_{}; + // Address-keyed pool of owed freed-slot notifications; loop() resends. + // Proxy-only state, kept off BluetoothConnection; entries are not tied to + // slot indices. + std::array pending_disconnections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below @@ -260,7 +313,11 @@ class BluetoothProxy final : public Component { bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // A dropped push (full TX buffer) is re-queried from the hub and resent + // from loop(); the hub's current state is idempotent by construction. + bool scanner_state_pending_{false}; +#else bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; From 8728aaa6163d163219ba0447e0d3c73634e36d2a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:21:03 +1200 Subject: [PATCH 082/597] [core] Use MAC address size constants in BLE components (#18252) --- esphome/components/api/api_connection.cpp | 4 ++-- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 12 +++++------ esphome/components/bk72xx_ble/bk72xx_ble.h | 20 +++++++++---------- .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 4 ++-- .../components/ble_device_base/ble_device.cpp | 2 +- .../components/ble_device_base/ble_device.h | 2 +- .../ble_device_base/scan_response_merger.cpp | 8 +++++--- .../ble_device_base/scan_response_merger.h | 2 +- .../bluetooth_connection_hub.cpp | 2 +- .../bluetooth_connection_rp2.cpp | 3 ++- .../bluetooth_proxy/bluetooth_proxy.cpp | 2 +- .../bluetooth_proxy/bluetooth_proxy.h | 5 +++-- esphome/components/esp32_ble/ble.cpp | 10 +++++----- esphome/components/esp32_ble/ble.h | 2 +- .../esp32_ble_tracker/esp32_ble_tracker.h | 2 +- esphome/components/ln882h_ble/ln882h_ble.cpp | 6 +++--- esphome/components/ln882h_ble/ln882h_ble.h | 6 +++--- .../ln882h_ble_tracker/ln882h_ble_tracker.h | 4 ++-- esphome/components/rp2040_ble/rp2040_ble.cpp | 9 ++++++--- esphome/components/rp2040_ble/rp2040_ble.h | 8 ++++---- .../rp2_ble_tracker/rp2_ble_tracker.h | 2 +- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 4 ++-- 22 files changed, 63 insertions(+), 56 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb57db9ce8..2d03052d63 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -23,6 +23,7 @@ #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING @@ -1849,8 +1850,7 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char bluetooth_mac[18]; + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac); #endif diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 954cb9fe87..d40f08d111 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -116,7 +116,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac, 6); + memcpy(report->mac, mac, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->evt_type = evt_type; @@ -230,7 +230,7 @@ void BK72xxBLE::loop() { ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); } -void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { +void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { for (int i = 0; i < 6; i++) out[i] = this->ble_mac_[i]; } @@ -263,7 +263,7 @@ void BK72xxBLE::resolve_mac_() { } } if (nonzero) { - memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); return; } #endif @@ -275,10 +275,10 @@ void BK72xxBLE::resolve_mac_() { // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment // would carry differently. - uint8_t wifi_mac[6]; + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(wifi_mac); // MSB-first - const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], - wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; // Store LSB-first to match recv_adv_t adv_addr ordering. for (int i = 0; i < 6; i++) this->ble_mac_[i] = ble[5 - i]; diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 7646f17161..687fd396e4 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -40,8 +40,8 @@ struct ScanParams { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the @@ -83,7 +83,7 @@ class BK72xxBLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } /// Controller BLE address, least-significant octet first (BLE convention). - void get_mac_lsb_first(uint8_t out[6]) const; + void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main task via loop()). @@ -135,13 +135,13 @@ class BK72xxBLE final : public Component { esphome::EventPool report_pool_; // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. uint32_t last_advance_ms_{0}; - uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change - uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none - uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS - int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none - ScanParams requested_{}; // latched by scan_start() - ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention) uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; bool scan_wanted_{false}; // the latched request is to scan (vs stopped) bool release_warned_{false}; // gates the release WARN; widens the pump gate diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 59d17f9b84..2334cfe414 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -116,8 +116,8 @@ class BK72xxBLETracker : public Component, bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index fc5bf5c1e0..23ca6b1dbd 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { // BLEScanResult's bda is most-significant octet first; the neutral ingest // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ // address_str_to() then produce exactly the historical esp32 values. - uint8_t mac_lsb_first[6]; + uint8_t mac_lsb_first[MAC_ADDRESS_SIZE]; for (uint8_t i = 0; i < 6; i++) mac_lsb_first[i] = scan_result.bda[5 - i]; this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index b5f198375c..668f7e09f8 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -241,7 +241,7 @@ class ESPBTDevice { // the 2-byte element header); every in-tree tracker scans legacy PDUs only. static constexpr uint8_t MAX_ADV_NAME_LEN = 29; - uint8_t address_[6]{0}; + uint8_t address_[MAC_ADDRESS_SIZE]{0}; uint8_t address_type_{0}; int rssi_{0}; // Fixed buffer instead of std::string: no per-advertisement heap churn on diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp index 2dd1fd6927..2c0d766683 100644 --- a/esphome/components/ble_device_base/scan_response_merger.cpp +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -2,6 +2,8 @@ #ifdef USE_BLE_SCAN_RESPONSE_MERGER +#include "esphome/core/helpers.h" + #include namespace esphome::ble_device_base { @@ -27,7 +29,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr free_slot = &p; continue; } - if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Same device advertised again before its scan response arrived — deliver // the previous advertisement (its scan response is not coming) and reuse // the slot, so no frame is ever lost. @@ -47,7 +49,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr } slot->used = true; this->pending_count_++; - memcpy(slot->mac, mac, 6); + memcpy(slot->mac, mac, MAC_ADDRESS_SIZE); slot->addr_type = addr_type; slot->rssi = rssi; slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); @@ -61,7 +63,7 @@ void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_ // hottest caller. if (this->pending_count_ != 0) { for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Append in place: the slot is released on delivery, so its 62-byte // buffer (legacy adv + scan response) holds the merged frame directly. const uint8_t room = sizeof(p.data) - p.data_len; diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h index 9415664fcf..f28790f207 100644 --- a/esphome/components/ble_device_base/scan_response_merger.h +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -120,7 +120,7 @@ class ScanResponseMerger { // as ESP-IDF delivers on ESP32. struct PendingAdv { bool used{false}; - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; uint8_t addr_type; int8_t rssi; uint8_t data_len; // <= sizeof(data) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index f79669dc32..0b5d996349 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -21,7 +21,7 @@ void BluetoothConnection::set_address(uint64_t address) { this->address_str_[0] = '\0'; return; } - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); format_mac_addr_upper(mac, this->address_str_); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc77d448a5..dea3b5d9c8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -5,6 +5,7 @@ #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -1098,7 +1099,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ } conn_err_t unpair_device(uint64_t address) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); bool found = false; BluetoothLock lock; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 6b49b28cd6..13c84b86d1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -140,7 +140,7 @@ void BluetoothProxy::dump_config() { // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. - char mac_str[18]; + char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 725429df24..cf7a09a7e5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -10,6 +10,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/bluetooth_connection/bluetooth_connection.h" @@ -201,8 +202,8 @@ class BluetoothProxy final : public Component { return flags; } - void get_bluetooth_mac_address_pretty(std::span output) { - uint8_t mac[6] = {}; + void get_bluetooth_mac_address_pretty(std::span output) { + uint8_t mac[MAC_ADDRESS_SIZE] = {}; this->hub_->get_adapter_mac(mac); // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn // the address once the link layer is up, and report all-zero until then. diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d11683ab35..16501ef3b2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif -void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { +void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { // The running stack owns the address (on hosted controllers it lives in // the remote chip's efuse); null before init becomes all-zero. const uint8_t *mac = esp_bt_dev_get_address(); if (mac != nullptr) { - memcpy(out, mac, 6); + memcpy(out, mac, MAC_ADDRESS_SIZE); } else { - memset(out, 0, 6); + memset(out, 0, MAC_ADDRESS_SIZE); } } float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - uint8_t mac_address[6]; + uint8_t mac_address[MAC_ADDRESS_SIZE]; this->get_mac_msb_first(mac_address); if (mac_address_is_valid(mac_address)) { const char *io_capability_s; @@ -713,7 +713,7 @@ void ESP32BLE::dump_config() { break; } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 45cfd8ee71..2a355a6c8b 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -109,7 +109,7 @@ class ESP32BLE final : public Component { void loop() override; void dump_config() override; /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 30b85b5417..7c3e5538fd 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_active() { return this->scan_active_; } // The mode is driven through this tracker's own API (see get_capabilities); diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 152ca571e9..021e138f08 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) { // downstream the value is used exactly like on ESP32. const int8_t raw = info->rssi; - memcpy(slot->mac, info->trans_addr, 6); + memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE); slot->rssi = (raw > 20) ? static_cast(-raw) : raw; slot->addr_type = info->trans_addr_type; slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; @@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); } if (!have_unique_addr) { - uint8_t wifi_mac[6] = {0}; + uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0}; get_mac_address_raw(wifi_mac); // MSB-first // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the @@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); } } - memcpy(this->ble_mac_, bt_addr.addr, 6); + memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE); } // --------------------------------------------------------------------------- diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index 2186822208..5b4a67b566 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t { /// One scan report from the controller, decoded from the SDK's rw-task event /// (RSSI already sign-corrected). struct BLEScanReport { - uint8_t mac[6]; // as the controller delivers it (LSB-first) - int8_t rssi; // signed dBm (-127..+20) + uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) uint8_t addr_type; bool is_scan_response; // report is a scan response (active scan) bool scannable; // advertisement may be followed by a scan response @@ -138,7 +138,7 @@ class LN882HBLE final : public Component { // Reports rejected by the legacy-only filter (rw-task producer, main-task // consumer via exchange in loop()). std::atomic rejected_reports_{0}; - uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; bool scanning_{false}; // controller scan running (re-entry guard for scan_start) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 2d88b938dd..dc42aebce9 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -90,8 +90,8 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 7dd84d9c31..80e8bf9415 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -2,6 +2,7 @@ #ifdef USE_RP2040_BLE +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -180,7 +181,7 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, // ESPHome main loop: bounded copy into the lock-free queue only. bd_addr_t addr; // accessor returns printable (MSB-first) order gap_event_advertising_report_get_address(packet, addr); - uint8_t mac_lsb[6]; + uint8_t mac_lsb[MAC_ADDRESS_SIZE]; reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), gap_event_advertising_report_get_address_type(packet), @@ -206,7 +207,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac_lsb_first, 6); + memcpy(report->mac, mac_lsb_first, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->adv_event_type = adv_event_type; @@ -217,7 +218,9 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, } // NOLINTEND(clang-analyzer-unix.Malloc) -void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); } +void RP2040BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + memcpy(out, this->ble_mac_, MAC_ADDRESS_SIZE); +} bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) { diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 99eb8cd88a..263a32106b 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -25,8 +25,8 @@ enum class BLEComponentState : uint8_t { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart uint8_t data_len; // bytes valid in data[] @@ -77,7 +77,7 @@ class RP2040BLE final : public Component { /// (LSB-first) order, hence the explicit names. All zeros until the stack /// reports ACTIVE (BTstack reads the address from the controller during /// power-up). - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef RP2040_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main loop via loop()). @@ -135,7 +135,7 @@ class RP2040BLE final : public Component { btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - uint8_t ble_mac_[6]{0}; // printable (MSB-first) order; zeros until ACTIVE + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // printable (MSB-first) order; zeros until ACTIVE BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 02bd7dc145..431f2daec7 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -71,7 +71,7 @@ class RP2BLETracker : public Component, } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scan_running_; } bool scan_active() { return this->scan_active_; } bool request_scan_mode(bool active); diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0a05950c5a..06c3a7ab7a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -293,7 +293,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c return false; } - uint8_t mac_reverse[6] = {0}; + uint8_t mac_reverse[MAC_ADDRESS_SIZE] = {0}; mac_reverse[5] = (uint8_t) (address >> 40); mac_reverse[4] = (uint8_t) (address >> 32); mac_reverse[3] = (uint8_t) (address >> 24); @@ -358,7 +358,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c #endif if (!decrypt_ok) { - uint8_t mac_address[6] = {0}; + uint8_t mac_address[MAC_ADDRESS_SIZE] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); memcpy(mac_address + 2, mac_reverse + 3, 1); From f697cf20113a2e8375ecd44bb682b188f0e304a8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:52:19 +1200 Subject: [PATCH 083/597] [api] Add DeviceCapabilities message for optional-feature flags (#17984) --- esphome/components/api/api.proto | 72 ++++ esphome/components/api/api_connection.cpp | 36 +- esphome/components/api/api_connection.h | 2 + esphome/components/api/api_pb2.cpp | 76 ++++ esphome/components/api/api_pb2.h | 68 ++++ esphome/components/api/api_pb2_dump.cpp | 49 +++ esphome/components/api/api_pb2_service.cpp | 7 + esphome/components/api/api_pb2_service.h | 2 + .../components/api/test_api_proto.py | 371 ++++++++++++++++++ 9 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/components/api/test_api_proto.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4b3df62ec4..88af5957e7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -19,6 +19,7 @@ service APIConnection { rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) { option (needs_authentication) = false; } + rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -243,6 +244,12 @@ message SerialProxyInfo { // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) // suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) +// +// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They +// have moved to that message as of API 1.15, but are still sent here so that +// older clients keep working. Do NOT mark them (deprecated) until the removal +// release: in this repo (deprecated) makes the generator drop the field +// entirely, so the device would stop sending it. message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -280,6 +287,8 @@ message DeviceInfoResponse { // Deprecated in API version 1.9 uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; + + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; string manufacturer = 12 [(max_data_length) = 20, (force) = true]; @@ -288,11 +297,14 @@ message DeviceInfoResponse { // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; + + // Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15. uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15. string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key @@ -305,10 +317,13 @@ message DeviceInfoResponse { AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; // Indicates if Z-Wave proxy support is available and features supported + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; // Serial proxy instance metadata + // Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15. repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; // Device is unprovisioned and accepts Noise handshakes with the well-known @@ -317,6 +332,63 @@ message DeviceInfoResponse { bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } +// ==================== DEVICE CAPABILITIES ==================== + +// Asks the device which optional features it supports. +// +// This message exists so that DeviceInfoResponse does not have to keep growing +// a flat list of feature flags. DeviceInfoResponse is served before +// authentication, so it is limited to identity information. Capabilities are +// only served on an authenticated connection (encrypted as well, when +// encryption is configured). +// +// Clients that see api_version >= 1.15 should read these values from +// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields. +// Older clients keep reading DeviceInfoResponse, which still carries the same +// values, so this is not a breaking change. +message DeviceCapabilitiesRequest { + option (id) = 149; + option (source) = SOURCE_CLIENT; + // Empty +} + +// Each feature gets its own sub-message so that it can gain fields over time +// without crowding the top-level field numbering. +// +// Note: a sub-message whose fields are all at their default value is not sent +// at all, so the presence of a sub-message is not a reliable test for "this +// feature is compiled in". Clients should test a value inside it, for example +// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse. + +message BluetoothProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + string mac_address = 2 [(max_data_length) = 17, (force) = true]; +} + +message VoiceAssistantCapabilities { + // Bitmask of the features this voice assistant supports + uint32 feature_flags = 1; +} + +message ZWaveProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + uint32 home_id = 2; +} + +message DeviceCapabilitiesResponse { + option (id) = 150; + option (source) = SOURCE_SERVER; + + BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; + ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + repeated SerialProxyInfo serial_proxies = 4 + [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; +} + message ListEntitiesRequest { option (id) = 11; option (source) = SOURCE_CLIENT; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2d03052d63..18eb2592ff 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1736,7 +1736,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 14; + resp.api_version_minor = 15; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -1904,6 +1904,35 @@ bool APIConnection::send_device_info_response_() { return this->send_message(resp); } +bool APIConnection::send_device_capabilities_response_() { + // These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks + // below in sync with send_device_info_response_() until those copies are removed. + DeviceCapabilitiesResponse resp; +#ifdef USE_BLUETOOTH_PROXY + resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); + resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac); +#endif +#ifdef USE_VOICE_ASSISTANT + resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); +#endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); + resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id(); +#endif +#ifdef USE_SERIAL_PROXY + size_t serial_proxy_index = 0; + for (auto const &proxy : App.get_serial_proxies()) { + if (serial_proxy_index >= SERIAL_PROXY_COUNT) + break; + auto &info = resp.serial_proxies[serial_proxy_index++]; + info.name = StringRef(proxy->get_name()); + info.port_type = proxy->get_port_type(); + } +#endif + return this->send_message(resp); +} void APIConnection::on_hello_request(const HelloRequest &msg) { if (!this->send_hello_response_(msg)) { this->on_fatal_error(); @@ -1925,6 +1954,11 @@ void APIConnection::on_device_info_request() { this->on_fatal_error(); } } +void APIConnection::on_device_capabilities_request() { + if (!this->send_device_capabilities_response_()) { + this->on_fatal_error(); + } +} #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7df7ea1429..9ca1b8b6a4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -266,6 +266,7 @@ class APIConnection final : public APIServerConnectionBase { void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); + void on_device_capabilities_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -385,6 +386,7 @@ class APIConnection final : public APIServerConnectionBase { bool send_disconnect_response_(); bool send_ping_response_(); bool send_device_info_response_(); + bool send_device_capabilities_response_(); #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 190bd32425..5776ec5c62 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const { #endif return size; } +#ifdef USE_BLUETOOTH_PROXY +uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + return pos; +} +uint32_t BluetoothProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += 2 + this->mac_address.size(); + return size; +} +#endif +#ifdef USE_VOICE_ASSISTANT +uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + return pos; +} +uint32_t VoiceAssistantCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + return size; +} +#endif +#ifdef USE_ZWAVE_PROXY +uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + return pos; +} +uint32_t ZWaveProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, this->home_id); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_BLUETOOTH_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); +#endif +#ifdef USE_VOICE_ASSISTANT + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); +#endif +#ifdef USE_ZWAVE_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + } +#endif + return pos; +} +uint32_t DeviceCapabilitiesResponse::calculate_size() const { + uint32_t size = 0; +#ifdef USE_BLUETOOTH_PROXY + size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); +#endif +#ifdef USE_VOICE_ASSISTANT + size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); +#endif +#ifdef USE_ZWAVE_PROXY + size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + size += ProtoSize::calc_message_force(1, it.calculate_size()); + } +#endif + return size; +} #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4d5866da0b..f35f551060 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -600,6 +600,74 @@ class DeviceInfoResponse final : public ProtoMessage { protected: }; +#ifdef USE_BLUETOOTH_PROXY +class BluetoothProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + StringRef mac_address{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_VOICE_ASSISTANT +class VoiceAssistantCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_ZWAVE_PROXY +class ZWaveProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint32_t home_id{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +class DeviceCapabilitiesResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 102; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } +#endif +#ifdef USE_BLUETOOTH_PROXY + BluetoothProxyCapabilities bluetooth_proxy{}; +#endif +#ifdef USE_VOICE_ASSISTANT + VoiceAssistantCapabilities voice_assistant{}; +#endif +#ifdef USE_ZWAVE_PROXY + ZWaveProxyCapabilities zwave_proxy{}; +#endif +#ifdef USE_SERIAL_PROXY + std::array serial_proxies{}; +#endif + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 09570b09e4..17ce7fba45 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -988,6 +988,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } +#ifdef USE_BLUETOOTH_PROXY +const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); + return out.c_str(); +} +#endif +#ifdef USE_VOICE_ASSISTANT +const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif +#ifdef USE_ZWAVE_PROXY +const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("home_id"), this->home_id); + return out.c_str(); +} +#endif +const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); +#ifdef USE_BLUETOOTH_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": "); + this->bluetooth_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_VOICE_ASSISTANT + out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": "); + this->voice_assistant.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_ZWAVE_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": "); + this->zwave_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": "); + it.dump_to(out); + out.append("\n"); + } +#endif + return out.c_str(); +} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 5c9df433dd..19dcbfb77c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif + case 149 /* DeviceCapabilitiesRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_capabilities_request")); +#endif + this->on_device_capabilities_request(); + break; + } default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index d1b51f4846..5ed78b3385 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,6 +27,8 @@ class APIServerConnectionBase { void on_ping_response(){}; void on_device_info_request(){}; + void on_device_capabilities_request(){}; + void on_list_entities_request(){}; void on_subscribe_states_request(){}; diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py new file mode 100644 index 0000000000..35aa5ff529 --- /dev/null +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -0,0 +1,371 @@ +"""Invariant tests for esphome/components/api/api.proto and its generated code. + +These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition +(API 1.15) against regressions that protoc-based codegen would not catch on +its own, without requiring protoc to be installed at test time: + +* script/api_protobuf/api_protobuf.py skips any field marked + `[deprecated = true]` completely -- it generates no C++ for it at all, so + the device silently stops sending that value. Six DeviceInfoResponse fields + were superseded by DeviceCapabilitiesResponse but must keep being sent for + backward compatibility with clients older than API 1.15. If a future edit + "tidies up" by marking one of them deprecated, this file breaks that field + for every existing client with nothing else in CI noticing. +* Field numbers are the wire protocol, not the field names. Renaming a field + is harmless; renumbering it is a silent breaking change, because an old + client still decodes by number. This file pins the field number of each of + the six superseded DeviceInfoResponse fields and of every field on the new + DeviceCapabilitiesResponse/BluetoothProxyCapabilities/ + VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a + well-intentioned reshuffle of api.proto gets caught here instead of on a + device in the field. +* Message wire ids must be unique, and the new capabilities RPC must stay + authenticated-only. + +Group A below asserts on the checked-in generated files (api_pb2.h / +api_pb2.cpp), since "the field is present in the generated C++" is exactly +equivalent to "the device still sends it". Group B parses api.proto as plain +text (no protoc). Group C checks the advertised API minor version. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import esphome + +API_DIR = Path(esphome.__file__).parent / "components" / "api" + +PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8") +HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8") +CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8") +API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8") + +# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse +# as of API 1.15 but must still be generated (and therefore still sent) for +# backward compatibility with older clients. +SUPERSEDED_FIELDS: dict[str, int] = { + "bluetooth_proxy_feature_flags": 15, + "voice_assistant_feature_flags": 17, + "bluetooth_mac_address": 18, + "zwave_proxy_feature_flags": 23, + "zwave_home_id": 24, + "serial_proxies": 25, +} + +# Field numbers on the new capability messages. These are a frozen wire +# contract from the moment they ship: an old client decodes a sub-message +# field purely by number, so renumbering any of these -- even without +# touching a name -- silently corrupts what every already-deployed client +# reads. Keyed by message name so the next capability sub-message is a +# data-only addition here. +NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { + "DeviceCapabilitiesResponse": { + "bluetooth_proxy": 1, + "voice_assistant": 2, + "zwave_proxy": 3, + "serial_proxies": 4, + }, + "BluetoothProxyCapabilities": { + "feature_flags": 1, + "mac_address": 2, + }, + "VoiceAssistantCapabilities": { + "feature_flags": 1, + }, + "ZWaveProxyCapabilities": { + "feature_flags": 1, + "home_id": 2, + }, +} + +# Fields that are genuinely dead and are expected to carry `deprecated=true`. +# Used to prove the deprecated-detection logic below actually detects +# deprecation rather than trivially passing. +GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = ( + "legacy_bluetooth_proxy_version", + "legacy_voice_assistant_version", +) + +DEPRECATED_FIELD_TRAP = ( + "script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = " + "true]` completely, generating no C++ for them at all. Marking this field " + "deprecated would silently stop the device from ever sending it, breaking " + "every existing client that still reads it from DeviceInfoResponse." +) + + +def _extract_braced_region(text: str, anchor_pattern: str) -> str: + """Return the region of `text` starting at the first match of + `anchor_pattern` up to the matching closing brace (inclusive), using + brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop + inside a function body) don't cause a premature stop. + """ + anchor_match = re.search(anchor_pattern, text) + if anchor_match is None: + raise AssertionError(f"could not find a match for {anchor_pattern!r}") + start = anchor_match.start() + open_brace = text.index("{", start) + depth = 0 + for i in range(open_brace, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}") + + +def _extract_class_body(header_text: str, class_name: str) -> str: + """Return the body of a generated C++ class, scoped so a field name that + also happens to exist on some other class cannot satisfy the assertion. + """ + return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b") + + +def _extract_function_body(cpp_text: str, qualified_name: str) -> str: + """Return the body of a generated `Class::method(...)` definition.""" + return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(") + + +def _extract_proto_message(proto_text: str, message_name: str) -> str: + """Return the body of a top-level `message Name { ... }` block from the + .proto source. Proto message bodies here contain no nested `{`/`}` of + their own (options use parens, not braces), so a non-greedy match up to + the first line that is just `}` is sufficient and keeps the parsing + simple. + """ + match = re.search( + rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}", + proto_text, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `message {message_name}` in api.proto") + return match.group(1) + + +def _extract_rpc_body(proto_text: str, rpc_name: str) -> str: + """Return the option body of an `rpc name (...) returns (...) { ... }` + declaration from the APIConnection service, robust to it being written + on one line (`{}`) or spread across several with options inside. + """ + match = re.search( + rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}", + proto_text, + re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto") + return match.group(1) + + +def _field_declaration_line(message_body: str, field_name: str) -> str: + """Return the single source line declaring `field_name` inside a proto + message body (all fields here are declared on one line). + """ + for line in message_body.splitlines(): + if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line): + return line + raise AssertionError( + f"could not find a field declaration for {field_name!r} in the given message body" + ) + + +# ==================== Group A: generated files ==================== + + +def test_superseded_device_info_fields_still_declared_in_header() -> None: + """Each superseded field must still be a real member of DeviceInfoResponse + in api_pb2.h -- not merely present somewhere in the file. Several of these + names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an + unscoped substring search over the whole header would pass even if the + field were removed from DeviceInfoResponse. + """ + class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse") + for field_name in SUPERSEDED_FIELDS: + assert re.search(rf"\b{field_name}\b", class_body), ( + f"{field_name} is missing from the DeviceInfoResponse class body in " + f"api_pb2.h. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_superseded_device_info_fields_still_encoded_and_sized() -> None: + """Each superseded field must still be touched by DeviceInfoResponse's + generated encode() and calculate_size(), i.e. it is still put on the wire. + """ + encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode") + size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size") + for field_name in SUPERSEDED_FIELDS: + assert f"this->{field_name}" in encode_body, ( + f"DeviceInfoResponse::encode() no longer references {field_name}. " + f"{DEPRECATED_FIELD_TRAP}" + ) + assert f"this->{field_name}" in size_body, ( + f"DeviceInfoResponse::calculate_size() no longer references " + f"{field_name}. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_new_capability_classes_present_in_header() -> None: + """The new response message and its capability sub-messages must exist as + generated classes. + """ + for class_name in ( + "DeviceCapabilitiesResponse", + "BluetoothProxyCapabilities", + "VoiceAssistantCapabilities", + "ZWaveProxyCapabilities", + ): + assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), ( + f"expected a generated class named {class_name} in api_pb2.h" + ) + + +# ==================== Group B: api.proto source text ==================== + + +def test_all_message_ids_are_unique() -> None: + """Every `option (id) = N;` in api.proto must be unique. Two messages + sharing a wire id would make the client and server misinterpret each + other's messages -- nothing else currently checks this. + """ + ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)] + assert ids, "did not find any `option (id) = N;` declarations in api.proto" + duplicates = sorted({value for value in ids if ids.count(value) > 1}) + assert not duplicates, ( + f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each " + "message must have a unique wire id." + ) + + +def test_device_capabilities_request_has_id_149() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`" + assert int(match.group(1)) == 149, ( + f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_device_capabilities_response_has_id_150() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`" + assert int(match.group(1)) == 150, ( + f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None: + """The six superseded fields must not carry `[deprecated = true]` in + api.proto, or the generator drops them and old clients stop receiving + them (see module docstring). The second half of this test proves the + deprecated-detection itself works: two genuinely dead fields + (legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must + still be detected as deprecated, so the first half isn't vacuously true. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name in SUPERSEDED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" not in line, ( + f"{field_name} in DeviceInfoResponse is marked deprecated in " + f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}" + ) + + for field_name in GENUINELY_DEPRECATED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" in line, ( + f"expected {field_name} to still carry `deprecated=true` in " + f"api.proto ({line.strip()!r}). If this fails, the deprecated " + "detection used above is broken, and the sibling assertion that " + "the superseded fields are NOT deprecated is not testing anything." + ) + + +def test_superseded_fields_keep_their_wire_numbers() -> None: + """Each superseded field must stay on the field number recorded in + SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field + number, so renumbering one of these -- even without touching its name -- + would make an old client read a completely different value out of the + wire, with nothing else in CI noticing. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name, field_number in SUPERSEDED_FIELDS.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} in DeviceInfoResponse is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field numbers " + "are the wire protocol -- renumbering this field silently breaks " + "every existing client that still decodes DeviceInfoResponse by " + "the old numbering." + ) + + +def test_capability_message_fields_keep_their_wire_numbers() -> None: + """Every field on DeviceCapabilitiesResponse and its three capability + sub-messages must stay on the field number recorded in + NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but + the moment a device ships with them, their field numbers are a frozen + wire contract -- a client decodes a sub-message field purely by number, + so a later "cleanup" that renumbers one of these would silently corrupt + what every already-deployed client reads, with nothing else in CI + noticing. + """ + for message_name, fields in NEW_CAPABILITY_FIELDS.items(): + body = _extract_proto_message(PROTO_TEXT, message_name) + for field_name, field_number in fields.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} on {message_name} is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field " + "numbers are the wire protocol -- renumbering this field " + "silently breaks every existing client that decodes this " + "message by the old numbering." + ) + + +def test_device_capabilities_rpc_requires_authentication() -> None: + """The `device_capabilities` RPC must not set + `option (needs_authentication) = false;` (or set it to anything at all). + Leaving it unset makes it inherit needs_authentication = true, keeping + capability data behind authentication (and encryption, when configured). + """ + body = _extract_rpc_body(PROTO_TEXT, "device_capabilities") + assert "needs_authentication" not in body, ( + "rpc device_capabilities sets a `needs_authentication` option in " + "api.proto. It must stay unset so it inherits needs_authentication = " + "true; otherwise device capability data could be requested over an " + "unauthenticated connection." + ) + + +# ==================== Group C: advertised API version ==================== + + +def test_api_version_minor_is_at_least_15() -> None: + """Clients gate sending DeviceCapabilitiesRequest on seeing + api_version >= 1.15 in HelloResponse. Regressing api_version_minor below + 15 would make every client believe capabilities are unsupported even + though the RPC exists, so this must never go backwards. Use >= rather + than == so the next unrelated minor-version bump doesn't need to touch + this test. + """ + match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT) + assert match is not None, ( + "could not find `resp.api_version_minor = N;` in api_connection.cpp" + ) + minor = int(match.group(1)) + assert minor >= 15, ( + f"api_version_minor is {minor}, but device_capabilities requires " + "clients to see api_version >= 1.15 in HelloResponse before they will " + "ever request it." + ) From 51b0240fb8a3c31a6d33070e6cb5099ef67da78f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 22:05:19 -0500 Subject: [PATCH 084/597] [rp2] Fix %f formatting in logs (#18256) --- esphome/components/rp2/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 1bf01e6828..3bc2df7a61 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -351,6 +351,11 @@ async def to_code(config): ], ) + # newlib-nano is the default libc for the arduino-pico toolchain and its + # printf silently drops %f unless _printf_float is force-linked. Components + # use %f widely in logging, so pull it in. + cg.add_build_flag("-Wl,-u,_printf_float") + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r # (~9.2 KB). See printf_stubs.cpp for implementation. if config.get(CONF_ENABLE_FULL_PRINTF): From 9823ad6abcdc56edb03d9a76819f9e0083ddd5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Aug 2026 23:56:32 -0500 Subject: [PATCH 085/597] [tests] Fix flaky modbus server/controller integration tests (#18258) --- tests/integration/state_utils.py | 40 ++++++++++++++++++---- tests/integration/test_uart_mock_modbus.py | 23 ++++++++++--- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 4d31644559..9c0debbc5c 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -420,8 +420,15 @@ class SensorTracker: """Call ``expect`` for every entry and return a dict of futures.""" return {name: self.expect(name, value) for name, value in expected.items()} - def on_state(self, state: EntityState) -> None: - """State callback suitable for ``subscribe_states``.""" + def on_state(self, state: EntityState, first_pending_only: bool = False) -> None: + """State callback suitable for ``subscribe_states``. + + Args: + state: The state update to record + first_pending_only: Only allow the first pending expectation for this + sensor to match, instead of the first matching one. Used for + connect-time states so they cannot satisfy a later phase. + """ if ( not isinstance(state, (SensorState, BinarySensorState)) or state.missing_state @@ -432,11 +439,13 @@ class SensorTracker: return self.sensor_states[sensor_name].append(state.state) for expected_value, future in self._expectations.get(sensor_name, []): - if not future.done() and ( - expected_value is self._ANY or state.state == expected_value - ): + if future.done(): + continue + if expected_value is self._ANY or state.state == expected_value: future.set_result(True) break + if first_pending_only: + break async def await_change( self, future: asyncio.Future, name: str, timeout: float = 2.0 @@ -474,8 +483,22 @@ class SensorTracker: for name, future in futures.items(): await self.await_change(future, name, timeout=timeout) - async def setup_and_start_scenario(self, client) -> list: - """Wire up subscriptions, wait for initial states, press Start Scenario.""" + async def setup_and_start_scenario( + self, client: APIClient, match_initial_states: bool = False + ) -> list[EntityInfo]: + """Wire up subscriptions, wait for initial states, press Start Scenario. + + Args: + client: The connected API client + match_initial_states: Also match expectations against the states the + device sends when the client connects, so a value published before + the client subscribed still counts. Binary sensors need this: they + drop repeats, so a value that lands in the connect-time dump is + never sent again. Plain sensors publish on every poll, so there it + only saves waiting for the next one. Only the first pending + expectation per sensor can match, so a connect-time value cannot + satisfy a later phase. + """ entities, _ = await client.list_entities_services() self.key_to_sensor.update( build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) @@ -488,6 +511,9 @@ class SensorTracker: import pytest pytest.fail("Timeout waiting for initial states") + if match_initial_states: + for state in initial_state_helper.initial_states.values(): + self.on_state(state, first_pending_only=True) start_btn = find_entity(entities, "start_scenario", ButtonInfo) assert start_btn is not None, "Start Scenario button not found" client.button_command(start_btn.key) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 17ab21f873..057d419fd8 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -330,7 +330,10 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -392,7 +395,12 @@ async def test_uart_mock_modbus_server_controller_write( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - entities = await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the baseline can already be in the + # states the device sends on connect; matching it there saves waiting for + # the next poll + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) # Wait for initial baseline values to confirm the controller <-> server # connection is working before issuing writes @@ -456,7 +464,11 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - entities = await tracker.setup_and_start_scenario(client) + # The controller polls from boot and binary sensors drop repeats, so the + # baseline can arrive only in the states the device sends on connect + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) # Wait for initial baseline values to confirm the controller <-> server # connection is working before issuing writes @@ -491,7 +503,10 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) From ecec3a19c7264e63a8756637caa9f9ed3c4e8f45 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:22:20 +1200 Subject: [PATCH 086/597] [debug] Remove Arduino core dependency from RP2 platform code (#18267) --- esphome/components/debug/debug_rp2.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index 336e9c7e06..4ace4be0a3 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,8 +1,9 @@ #include "debug_component.h" #ifdef USE_RP2 #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include #include #if defined(PICO_RP2350) #include @@ -68,13 +69,14 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } +// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here. +uint32_t DebugComponent::get_free_heap_() { return RAMAllocator().get_free_heap_size(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = RP2040::f_cpu(); + uint32_t cpu_freq = clock_get_hz(clk_sys); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); From e5cdda9ee5c993efd5b39f5c82b81458e98534cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:22:52 +1200 Subject: [PATCH 087/597] [adc] Fix RP2350B internal temperature reading wrong ADC channel (#18270) --- esphome/components/adc/adc_sensor_rp2.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 6cb9ef113f..2732f5328b 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -52,7 +52,11 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - adc_select_input(4); + // The on-die temperature sensor sits on the last ADC channel, and which one + // that is depends on the chip: input 4 on RP2040 and RP2350A, but input 8 on + // RP2350B, which has eight external channels instead of four. The SDK + // resolves it for the target being built, so do not hardcode it. + adc_select_input(ADC_TEMPERATURE_CHANNEL_NUM); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); From 2ff55e305825557c66dbd916eb61eea92ebb9644 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:23:08 +1200 Subject: [PATCH 088/597] [rp2] Use SDK clock query directly in arch_get_cpu_freq_hz (#18269) --- esphome/components/rp2/hal.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/rp2/hal.cpp b/esphome/components/rp2/hal.cpp index 8eb1b469bc..ac1467e5e6 100644 --- a/esphome/components/rp2/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -7,6 +7,7 @@ #include "crash_handler.h" #endif +#include "hardware/clocks.h" #include "hardware/watchdog.h" // Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. @@ -33,7 +34,8 @@ void arch_init() { #endif } -uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } +// clock_get_hz(clk_sys) is the SDK query for the current system clock frequency in Hz. +uint32_t arch_get_cpu_freq_hz() { return clock_get_hz(clk_sys); } } // namespace esphome From 04dd6b3a553a79ccf4173c4e9b778134714c6917 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:27:32 +1200 Subject: [PATCH 089/597] [core] Use MAC address size constants instead of literals (#18254) --- esphome/components/api/api_connection.cpp | 5 ++--- esphome/components/captive_portal/captive_portal.cpp | 2 +- esphome/components/debug/debug_esp32.cpp | 3 ++- esphome/components/esp32/helpers.cpp | 2 +- esphome/components/ethernet/ethernet_component.h | 4 ++-- esphome/components/ethernet/ethernet_component_esp32.cpp | 8 ++++---- esphome/components/ethernet/ethernet_component_rp2.cpp | 4 ++-- esphome/components/host/helpers.cpp | 2 +- esphome/components/tinyusb/tinyusb_component.cpp | 2 +- esphome/components/wake_on_lan/wake_on_lan.h | 3 ++- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 4 ++-- esphome/components/wifi/wifi_component_esp8266.cpp | 2 +- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++-- esphome/components/wifi/wifi_component_libretiny.cpp | 4 ++-- esphome/components/wifi_info/wifi_info_text_sensor.cpp | 3 ++- esphome/components/wifi_info/wifi_info_text_sensor.h | 2 +- esphome/core/alloc_helpers.cpp | 6 +++--- esphome/core/helpers.cpp | 4 ++-- 19 files changed, 34 insertions(+), 32 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 18eb2592ff..19d2b14a32 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1772,9 +1772,8 @@ bool APIConnection::send_device_info_response_() { #ifdef USE_AREAS resp.suggested_area = StringRef(App.get_area()); #endif - // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char mac_address[18]; - uint8_t mac[6]; + char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 8094903008..704a61d4de 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -14,7 +14,7 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 7c01f9b54f..969cd840cf 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); ESP_LOGD(TAG, diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index afcec8bfc7..c2ff6cf34d 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -109,7 +109,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); } bool has_custom_mac_address() { #if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC) - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; // do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails #ifndef USE_ESP32_VARIANT_ESP32 return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) && diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index dc084796e7..ad329f9b81 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -144,7 +144,7 @@ class EthernetComponent final : public Component { #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); #endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); @@ -336,7 +336,7 @@ class EthernetComponent final : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - optional> fixed_mac_; + optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS StaticVector ip_state_listeners_; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 7cf8cdf736..dc623b6e5b 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -429,9 +429,9 @@ void EthernetComponent::ethernet_lazy_init_() { #endif // !USE_ETHERNET_SPI // use ESP internal eth mac - uint8_t mac_addr[6]; + uint8_t mac_addr[MAC_ADDRESS_SIZE]; if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); + memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac_addr, ESP_MAC_ETH); } @@ -926,7 +926,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. if (this->fixed_mac_.has_value()) { - memcpy(mac, this->fixed_mac_->data(), 6); + memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac, ESP_MAC_ETH); } @@ -944,7 +944,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 4d6d6c4f5b..119e447689 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -245,7 +245,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (this->eth_ != nullptr) { this->eth_->macAddress(mac); } else { - memset(mac, 0, 6); + memset(mac, 0, MAC_ADDRESS_SIZE); } } @@ -256,7 +256,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7e8849b3e1..7274d9de57 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS; + static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); } diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index b748959571..c8c36f0ffb 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -12,7 +12,7 @@ static const char *const TAG = "tinyusb"; void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { - static char mac_addr_buf[13]; + static char mac_addr_buf[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr_buf); this->string_descriptor_[SERIAL_NUMBER] = mac_addr_buf; } diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index ddf3433e7d..cef60c54f8 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -3,6 +3,7 @@ #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" #else @@ -27,7 +28,7 @@ class WakeOnLanButton final : public button::Button, public Component { #endif void press_action() override; uint16_t port_{9}; - uint8_t macaddr_[6]; + uint8_t macaddr_[MAC_ADDRESS_SIZE]; }; } // namespace esphome::wake_on_lan diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3fe3979a8b..9e50b7a394 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -510,7 +510,7 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; response->addHeader(ESPHOME_F("Private-Network-Access-ID"), get_mac_address_pretty_into_buffer(mac_s)); request->send(response); } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9e78e7c48e..127eb50df1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1117,7 +1117,7 @@ void WiFiComponent::connect_soon_() { void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; int8_t priority = 0; if (ap.has_bssid()) { @@ -2068,7 +2068,7 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); this->set_sta_priority(failed_bssid.value(), new_priority); } - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(failed_bssid.value().data(), bssid_s); ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "", bssid_s, old_priority, new_priority); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index e082b2c8c1..719a276bf9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -516,7 +516,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { (const char *) it.ssid); global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_NOT_FOUND); } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, LOG_STR_ARG(get_disconnect_reason_str(it.reason))); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 783c000f7b..0198f899d5 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -140,7 +140,7 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi } void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; if (has_custom_mac_address()) { get_mac_address_raw(mac); set_mac_address(mac); @@ -860,7 +860,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGI(TAG, "Disconnected ssid='%.*s' reason='Station Roaming'", it.ssid_len, (const char *) it.ssid); return; } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ce9c4eb6ce..66c397a8ad 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -81,7 +81,7 @@ struct LTWiFiEvent { uint8_t scan_id; } scan_done; struct { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; int rssi; } ap_probe_req; } data; @@ -391,7 +391,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { auto &it = info.wifi_ap_probereqrecved; - memcpy(to_send->data.ap_probe_req.mac, it.mac, 6); + memcpy(to_send->data.ap_probe_req.mac, it.mac, MAC_ADDRESS_SIZE); to_send->data.ap_probe_req.rssi = it.rssi; break; } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index b5ebfd7390..5d4e77eaad 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -1,5 +1,6 @@ #include "wifi_info_text_sensor.h" #ifdef USE_WIFI +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #ifdef USE_ESP8266 @@ -125,7 +126,7 @@ void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_lis void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } void BSSIDWiFiInfo::on_wifi_connect_state(StringRef ssid, std::span bssid) { - char buf[18] = "unknown"; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 7ade170c02..eecedee133 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -87,7 +87,7 @@ class PowerSaveModeWiFiInfo final : public Component, class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->publish_state(get_mac_address_pretty_into_buffer(mac_s)); } void dump_config() override; diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 27c50ebb2a..d9cfad70b9 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -144,7 +144,7 @@ std::vector base64_decode(const std::string &encoded_string) { // --- Hex/binary formatting helpers --- std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac, buf); return std::string(buf); } @@ -206,9 +206,9 @@ std::string format_bin(const uint8_t *data, size_t length) { // --- MAC address helpers --- std::string get_mac_address() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); - char buf[13]; + char buf[MAC_ADDRESS_BUFFER_SIZE]; format_mac_addr_lower_no_sep(mac, buf); return std::string(buf); } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index c8cf85d7d6..8c4442f1b2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -808,13 +808,13 @@ void HighFrequencyLoopRequester::stop() { // get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp void get_mac_address_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } const char *get_mac_address_pretty_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); From 069f40f6533b8b8a6cf5001e77103544572ff0bd Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 11 Aug 2026 15:09:33 +0200 Subject: [PATCH 090/597] [hoermann_hcp] Add connectivity binary sensor (#18189) Co-authored-by: J. Nick Koston --- .../hoermann_hcp/binary_sensor/__init__.py | 36 ++++++++++ .../hoermann_hcp_binary_sensor.cpp | 18 +++++ .../hoermann_hcp_binary_sensor.h | 20 ++++++ .../hoermann_hcp_binary_sensor_test.cpp | 72 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 5 ++ 5 files changed, 151 insertions(+) create mode 100644 esphome/components/hoermann_hcp/binary_sensor/__init__.py create mode 100644 esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp create mode 100644 esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h create mode 100644 tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py new file mode 100644 index 0000000000..3de6a161e6 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_IS_CONNECTED = "is_connected" + +HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( + "HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema( + HoermannHcpConnectedBinarySensor, + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.COMPONENT_SCHEMA), + } + ), + cv.has_at_least_one_key(CONF_IS_CONNECTED), +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_IS_CONNECTED)) is not None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await binary_sensor.new_binary_sensor(conf, parent) + await cg.register_component(var, conf) diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp new file mode 100644 index 0000000000..edce6ce4c2 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -0,0 +1,18 @@ +#include "hoermann_hcp_binary_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.binary_sensor"; + +void HoermannHcpConnectedBinarySensor::setup() { + // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive + // their timers. + this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); }); + this->publish_initial_state(this->parent_->is_valid()); +} + +void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); } + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h new file mode 100644 index 0000000000..c111c17834 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + HoermannHcp *const parent_; +}; + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp new file mode 100644 index 0000000000..3cf708c19e --- /dev/null +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -0,0 +1,72 @@ +#include + +#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// Exposes the connection bookkeeping so a drop can be driven without waiting one out. +class TestableHoermannHcp : public HoermannHcp { + public: + using HoermannHcp::set_valid_; +}; + +} // namespace + +// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. +TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +// The connection flag follows the bus controller in both directions. +TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { + TestableHoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + ASSERT_FALSE(sensor.state); + + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.set_valid_(false); + door.update(); + EXPECT_FALSE(sensor.state); +} + +// Any hub change re-runs the publish path, so an unchanged connection must not be reported twice. +TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + ASSERT_EQ(publishes, 1); + + // A status broadcast changes the door state without touching the connection. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + EXPECT_EQ(publishes, 1); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 3b77eed7ea..84162e8812 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -6,3 +6,8 @@ cover: - platform: hoermann_hcp name: Garage Door device_class: garage + +binary_sensor: + - platform: hoermann_hcp + is_connected: + name: Garage Connected From e8852c59501c525603375543afdc3f8f18306e6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:11:23 -0500 Subject: [PATCH 091/597] [core] Flush stdout in safe_print so logs stream live (#18261) --- esphome/util.py | 14 ++++++++-- tests/unit_tests/test_util.py | 49 +++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/esphome/util.py b/esphome/util.py index 136d6362f2..5bb341b700 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -87,8 +87,11 @@ def safe_print(message="", end="\n"): except UnicodeEncodeError: pass + # Always flush: stdout is block buffered when it is a pipe (the dashboard + # runs us that way), so live log lines would otherwise sit in the buffer + # for a long time instead of streaming out. try: - print(message, end=end) + print(message, end=end, flush=True) return except UnicodeEncodeError: pass @@ -104,6 +107,7 @@ def safe_print(message="", end="\n"): print( message.encode(encoding, "backslashreplace").decode(encoding), end=end, + flush=True, ) return except UnicodeEncodeError: @@ -113,9 +117,10 @@ def safe_print(message="", end="\n"): print( message.encode("ascii", "backslashreplace").decode("ascii"), end=end, + flush=True, ) except UnicodeEncodeError: - print("Cannot print line because of invalid locale!") + print("Cannot print line because of invalid locale!", flush=True) def safe_input(prompt=""): @@ -211,6 +216,11 @@ class RedirectText: else: self._write_color_replace(s) + # Same reason as safe_print: the dashboard gives us a pipe, which is + # block buffered, so in-process esptool progress would not show up + # until the buffer filled. + self._out.flush() + # write() returns the number of characters written # Let's print the number of characters of the original string in order to not confuse # any caller. diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 02309fbff8..bd3d3d4836 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -422,6 +422,26 @@ def _make_redirect( return redirect, buf +def test_redirect_text_flushes_so_piped_output_streams() -> None: + """Regression: in-process esptool progress must reach the pipe right away. + + ``run_external_command`` runs esptool inside our own process, so its + progress output goes through ``RedirectText.write``. That used to be + flushed only because ``colorama.init()`` wrapped stdout in a stream that + flushed after every write. + """ + buf = io.BytesIO() + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + redirect = util.RedirectText(piped_stream) + + redirect.write("Writing at 0x00010000 (50%)\r") + + # No explicit flush here on purpose: RedirectText has to do it. + assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" + + def test_redirect_text_callback_called_on_matching_line() -> None: """Test that a line callback is called and its output is written.""" results: list[str] = [] @@ -745,6 +765,31 @@ class TestSafePrint: util.safe_print("\033[0;32mhi\033[0m") assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n" + def test_flushes_so_piped_output_streams( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: each line must reach the OS pipe right away. + + The dashboard runs ``esphome logs`` with stdout as a pipe, which + Python block buffers at 8 KiB. Log lines used to be flushed only + because ``colorama.init()`` wrapped stdout in a stream that flushed + after every write; once that wrapping was skipped for dashboard runs + the lines sat in the buffer and the log view stayed empty until + enough output piled up to fill it. + """ + buf = io.BytesIO() + # newline="\n" keeps Windows from rewriting the terminator to "\r\n"; + # this test is about flushing, not about line endings. + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + monkeypatch.setattr(sys, "stdout", piped_stream) + + util.safe_print("live log line") + + # No explicit flush here on purpose: safe_print has to do it. + assert buf.getvalue() == b"live log line\n" + def test_fallback_writes_string_not_bytes_repr( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -764,7 +809,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("bars: \u2582\u2584\u2586\u2588 done") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Output is a clean line, not the bytes repr. @@ -789,7 +834,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Dashboard escaping turned ESC into literal "\033" (5 chars), which From eefd2a00c753ea0ca740000fc85cfd9e1f91f5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:11:42 -0500 Subject: [PATCH 092/597] [espidf] Flush the runner's output so dashboard builds stream (#18264) --- esphome/espidf/runner.py | 29 ++-- .../fixtures/espidf/filtering_probe.py | 15 +++ .../fixtures/espidf/streaming_probe.py | 14 ++ tests/unit_tests/test_espidf_runner.py | 127 ++++++++++++++++++ 4 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/fixtures/espidf/filtering_probe.py create mode 100644 tests/unit_tests/fixtures/espidf/streaming_probe.py create mode 100644 tests/unit_tests/test_espidf_runner.py diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7c568db7be..9e1f24d5ed 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -187,20 +187,25 @@ def main() -> int: if self._filter_pattern is None: self._stream.write(data) - return len(data) + else: + self._line_buffer += data + for line in self._line_buffer.splitlines(keepends=True): + if "\n" not in line and "\r" not in line: + # Incomplete — hold until we see a terminator. + self._line_buffer = line + break + self._line_buffer = "" - self._line_buffer += data - for line in self._line_buffer.splitlines(keepends=True): - if "\n" not in line and "\r" not in line: - # Incomplete — hold until we see a terminator. - self._line_buffer = line - break - self._line_buffer = "" + stripped = ansi_escape.sub("", line).rstrip() + if self._filter_pattern.match(stripped) is not None: + continue + self._stream.write(line) - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - continue - self._stream.write(line) + # We tell idf.py it is talking to a terminal, so it sends progress + # bars and cursor moves. Our own stdout is usually a pipe, which is + # block buffered, so without this the build looks frozen until + # 8 KiB of output piles up. + self._stream.flush() return len(data) if len(sys.argv) < 2: diff --git a/tests/unit_tests/fixtures/espidf/filtering_probe.py b/tests/unit_tests/fixtures/espidf/filtering_probe.py new file mode 100644 index 0000000000..04c2b2ed8c --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/filtering_probe.py @@ -0,0 +1,15 @@ +"""Write a mix of noisy and useful build lines, without flushing. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner's shim owns both the filtering and the flushing, so this script +only writes. +""" + +import sys + +sys.stdout.write("Project build complete.\n") +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("-- Component paths: /a /b /c\n") +sys.stdout.write("[2/9] Building C object\n") +# No terminator, so the shim has to hold this one back. +sys.stdout.write("still going") diff --git a/tests/unit_tests/fixtures/espidf/streaming_probe.py b/tests/unit_tests/fixtures/espidf/streaming_probe.py new file mode 100644 index 0000000000..c05741e311 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/streaming_probe.py @@ -0,0 +1,14 @@ +"""Print one line, then stay alive so the caller can prove it streamed. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner wraps stdout in its filtering shim, so this script deliberately +does not flush: the shim has to do it. The long sleep keeps the process +running, so anything the caller reads must have arrived while the build +was still going rather than at exit. +""" + +import sys +import time + +sys.stdout.write("Compiling main.cpp\n") +time.sleep(60) diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py new file mode 100644 index 0000000000..831c8d1cc8 --- /dev/null +++ b/tests/unit_tests/test_espidf_runner.py @@ -0,0 +1,127 @@ +"""Tests for esphome.espidf.runner.""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +import subprocess +import sys +import threading + +import pytest + +from esphome.espidf import runner + +# A flushing runner delivers the first line in well under a second; this is +# only ever waited out when the shim has gone back to buffering, so keep it +# just long enough to cover interpreter startup on a loaded CI machine. +FIRST_LINE_TIMEOUT = 10.0 + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Run ``runner.main()`` in-process against a buffered fake stdout. + + ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and + ``os.get_terminal_size``; every one of those is monkeypatched so it is + put back afterwards. The fake stdout is block buffered like a pipe, so + the caller can tell whether the shim flushed. The wrapper comes back with + the buffer because dropping it would close the buffer underneath us. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) + + assert runner.main() == 0 + return buf, stream + + +def test_main_filters_noise_and_flushes_each_write( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Useful lines reach the stream right away; noisy ones are dropped.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py" + ) + + # Read before any flush of our own: the shim has to have flushed. + output = buf.getvalue().decode("utf-8") + + assert "Compiling main.cpp\n" in output + assert "[2/9] Building C object\n" in output + # Matched by FILTER_IDF_LINES, so they never leave the runner. + assert "Project build complete." not in output + assert "-- Component paths:" not in output + # Held back because no terminator arrived. + assert "still going" not in output + + +def test_main_keeps_everything_in_verbose_mode( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """``-v`` turns the filter off so the noisy lines survive.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v" + ) + + output = buf.getvalue().decode("utf-8") + + assert "Project build complete.\n" in output + assert "-- Component paths: /a /b /c\n" in output + # With no filter there is no line buffering, so the partial line goes + # straight through as well. + assert output.endswith("still going") + + +def test_runner_streams_output_before_the_build_finishes( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """The runner must flush, or a dashboard build looks frozen. + + ``toolchain.py`` spawns the runner as a plain script with no ``-u``, and + hands it a pipe when esphome itself is running under the dashboard. A + pipe is block buffered, so without a flush in the shim's ``write()`` the + output sits in the child until 8 KiB piles up or the build ends. + """ + runner_py = Path(runner.__file__) + probe = fixture_path / "espidf" / "streaming_probe.py" + + with subprocess.Popen( + [sys.executable, str(runner_py), str(probe)], + stdout=subprocess.PIPE, + # Keep stderr: if the runner dies on startup, its traceback is the + # only clue about why no line showed up. + stderr=subprocess.PIPE, + env=probe_env, + text=True, + ) as proc: + assert proc.stdout is not None + assert proc.stderr is not None + first_line: list[str] = [] + reader = threading.Thread( + target=lambda: first_line.append(proc.stdout.readline()), daemon=True + ) + try: + reader.start() + reader.join(FIRST_LINE_TIMEOUT) + still_running = proc.poll() is None + + # The probe sleeps for a minute after writing, so reaching us at + # all means the line was flushed rather than released at exit. + assert first_line == ["Compiling main.cpp\n"], ( + f"runner stderr: {'' if still_running else proc.stderr.read()}" + ) + assert still_running + finally: + proc.kill() + proc.wait() + # Join before leaving the block, so the reader is done rather than + # racing ``Popen`` closing the pipe under it. + reader.join(1.0) From 8ee3c8d41d50368f9839535e8ca8125caa54777d Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 11 Aug 2026 06:31:15 -0700 Subject: [PATCH 093/597] [modbus] Properly support client-mode broadcast sends (#17467) Co-authored-by: J. Nick Koston --- esphome/components/modbus/modbus.cpp | 27 +++- esphome/components/modbus/modbus.h | 35 ++-- .../components/modbus_client/modbus_client.h | 3 +- .../modbus_controller/modbus_controller.cpp | 18 ++- .../modbus/modbus_client_hub_test.cpp | 149 +++++++++++++++++ .../uart_mock_modbus_broadcast_write.yaml | 150 ++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 38 +++++ 7 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index cff086aeea..5305f6313f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -815,6 +815,16 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); + if (cmd->frame.address() == BROADCAST_ADDRESS) { + // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above + // reports the transmission, and the entry then retires with no terminal callback instead of + // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already + // spaces the next frame; the following sweep erases the entry. + ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)"); + cmd->complete_broadcast(); + this->sweep_needed_ = true; + return; + } this->waiting_for_response_ = true; } @@ -1033,9 +1043,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); return false; } + // classify() drives both the broadcast guard and the continuous check below; compute it once. + const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]); + + // A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that + // changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code - + // as it could never deliver a result, so the caller learns via the false return (and on_not_sent). + // 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half + // lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom + // code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly + // here to match classify()'s exception-first handling of the write side. + if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE && + (!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } // continuous is ignored for every mutating code (re-writing a value forever is never intended). - const bool mutates = ModbusDeviceCommand::classify(pdu[0]) == CommandPriority::WRITE; + const bool mutates = priority == CommandPriority::WRITE; bool continuous = false; if (options.continuous) { if (mutates) { diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 6bd407a687..dfe4a4872d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -171,6 +171,15 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already + // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal + // callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing. + // A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such + // code caps pending at 1, so pending is always 1 here - clear it. + void complete_broadcast() { + this->state = FrameState::RETIRED; + this->pending = 0; + } // Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++). void requeue(uint16_t seq) { this->state = FrameState::READY; @@ -270,7 +279,8 @@ class ModbusClientHub : public Modbus { }; /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in - /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets + /// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap /// duplicate) and no callback of any kind will follow; the false return is the whole story. bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, @@ -411,13 +421,14 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by /// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) -/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. -/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all -/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from +/// gets none, and a broadcast (address 0) gets on_sent() with NO terminal, since a broadcast is never +/// answered (Modbus 4.1). on_sent() is additional, once per transmission, never for an on_not_sent() +/// request. on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, +/// all from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from /// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal": -/// clear_tx_queue_for_device() drops the caller's own frames silently; a continuous poll's cycles are -/// its own accounting (a one-shot duplicate downgrades the poll to a one-shot; a continuous duplicate -/// merges into it). +/// a broadcast is fire-and-forget (on_sent, no terminal); clear_tx_queue_for_device() drops the caller's +/// own frames silently; a continuous poll's cycles are its own accounting (a one-shot duplicate +/// downgrades the poll to a one-shot; a continuous duplicate merges into it). /// /// Invariants: /// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing @@ -531,8 +542,9 @@ class ModbusClientDevice { this); } /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will - /// follow, false = refused at the door and nothing further happens. Neither means the frame is on - /// the wire; on_sent() reports that. + /// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()), + /// false = refused at the door and nothing further happens. Neither means the frame is on the wire; + /// on_sent() reports that. bool queue_pdu(std::span pdu, CommandOptions options = {}) { return this->parent_->queue_pdu(this->address_, pdu, this, options); } @@ -548,8 +560,9 @@ class ModbusClientDevice { this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } // The typed request builders below all queue through queue_pdu(), so they share its contract: true - // means the request is queued and will resolve in exactly one terminal callback, false means it was - // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. + // means the request is queued and will resolve in exactly one terminal callback (except a broadcast + // (address 0), which is never answered and so gets only on_sent()), false means it was refused outright + // with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 7e6d9d069f..20dc1a4745 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -64,7 +64,8 @@ template class ClientActionBase : public Action, public m protected: /// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full /// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every - /// send still gets exactly one outcome, so resolve refusals here via on_not_sent. + /// send still gets exactly one outcome (a broadcast (address 0) is the exception - never answered, it + /// resolves through on_sent() alone), so resolve refusals here via on_not_sent. /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 35f21fd0af..2c568938e4 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -109,8 +109,22 @@ void ModbusCommandItem::on_not_sent(std::span request_pdu) { // Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent // trigger reflects when the frame actually went out, not when it was queued. void ModbusCommandItem::on_sent(std::span request_pdu) { - if (this->controller_ != nullptr) - this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + if (this->controller_ == nullptr) + return; + this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + // A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback. + // on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak. + // Test the address the frame went to, not address_: a custom command's frame carries its own address + // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) + uint8_t wire_address = this->address_; + if (this->function_code_ == FunctionCode::CUSTOM) { + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (!frame.empty()) + wire_address = frame[0]; + } + if (wire_address == modbus::BROADCAST_ADDRESS) + this->controller_->unqueue_command(this); } bool ModbusCommandItem::on_no_response(std::span request_pdu) { diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index c2a36c0da7..43c81bbf34 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -684,6 +684,155 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { EXPECT_TRUE(hub.waiting()); } +namespace { +// Records on_sent / on_response / on_no_response so a broadcast's fire-and-forget completion +// (on_sent, and no terminal) can be asserted. +class BroadcastProbeDevice : public ModbusClientDevice { + public: + BroadcastProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_response(std::span request_pdu, std::span response_pdu) override { + this->response_count_++; + this->last_response_size_ = response_pdu.size(); + } + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + return false; + } + int sent_count_{0}; + int response_count_{0}; + int no_response_count_{0}; + size_t last_response_size_{0}; +}; +} // namespace + +// A broadcast (address 0) is never answered (Modbus 4.1), so the client treats it as fire-and-forget: +// on_sent fires as the frame goes out, NO terminal (on_response/on_error/on_no_response) is delivered, +// the hub is left NOT waiting - no timeout is burned - and the sweep erases the entry. +TEST(ModbusClientHubBroadcast, CompletesAtTransmissionWithoutWaiting) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); // no waiting slot occupied + EXPECT_EQ(hub.queued_frames(), 0u); // and the entry is gone + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Keeps the DEFAULT on_response() (so the base typed dispatcher runs) and records the typed write +// callback and the catch-all, to prove a broadcast reaches neither - only on_sent. +class BroadcastTypedProbeDevice : public ModbusClientDevice { + public: + BroadcastTypedProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override { + this->write_single_count_++; + } + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->custom_count_++; + } + int sent_count_{0}; + int write_single_count_{0}; + int custom_count_{0}; +}; +} // namespace + +// Completing a broadcast with an empty response({}) used to fall, for a device on the default +// on_response(), through the typed dispatcher to on_custom_response() - firing the wrong callback and +// logging a spurious "non-standard" warning. Fire-and-forget delivers no terminal at all, so a broadcast +// write reaches neither the typed write callback nor the catch-all: only on_sent. +TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastTypedProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // on_sent still reports the transmission + EXPECT_EQ(device.write_single_count_, 0); // no terminal: the typed write callback never fires + EXPECT_EQ(device.custom_count_, 0); // and it is NOT diverted to the catch-all (no false warning) + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be +// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently +// retiring it. Writes, 0x17, and custom codes still go through (covered above). +TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + EXPECT_FALSE(device.queue_pdu(read)); // refused: a broadcast read is never answered + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + +// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the +// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. +TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; // FC 0x41: first user-defined function code space + ASSERT_TRUE(device.queue_pdu(custom)); // accepted: a custom code is not a read + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); // the entry is gone +} + +// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks +// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling +// of an exception-flagged write. +TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t exception_custom[] = {0xC1, 0x01, 0x02}; // 0x41 | 0x80: custom code with the exception bit + EXPECT_FALSE(device.queue_pdu(exception_custom)); // refused: exception-flagged, never a real broadcast + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + namespace { // tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. class RejectPostDelayHub : public NoResponseProbeHub { diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml new file mode 100644 index 0000000000..8857bf8c96 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml @@ -0,0 +1,150 @@ +esphome: + name: uart-mock-modbus-broadcast + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true # controller polls at boot; forwarding must already be active + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +globals: + - id: srv1_reg + type: int + initial_value: "0" + - id: srv2_reg + type: int + initial_value: "0" + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + # Polling is off until the test has subscribed; the Start Scenario button starts it, so the + # first poll is never lost to a boot-time race ahead of the API subscription. + update_interval: never + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv1_reg); + write_lambda: |- + id(srv1_reg) = x; + return true; + - address: 2 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv2_reg); + write_lambda: |- + id(srv2_reg) = x; + return true; + +sensor: + # Normal polling continues before and after the broadcast: the old behavior burned a + # timeout per broadcast, which surfaces as modbus warnings and failed expectations here. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + # Republish every poll (the value is constant 919): the test observes successive publishes to + # prove polling continues before and after the broadcast, which dedup would otherwise hide. + force_update: true + # The servers' written values, published locally. + - platform: template + name: "srv1_written" + lambda: return id(srv1_reg); + update_interval: 0.2s + - platform: template + name: "srv2_written" + lambda: return id(srv2_reg); + update_interval: 0.2s + # Whether the hub accepted the broadcast into the transmit queue (the bool queue_pdu() returns). + - platform: template + name: "broadcast_accepted" + id: broadcast_accepted + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + // Start polling now that the test has subscribed. + id(modbus_controller_1).set_update_interval(1000); + id(modbus_controller_1).start_poller(); + // Broadcast (address 0) write single register: reg 0x10 = 777 on every server. + // PDU is function code + data (no address/CRC); the hub prepends address 0 and appends CRC. + const uint8_t pdu[] = {0x06, 0x00, 0x10, 0x03, 0x09}; + // queue_pdu() returns whether the broadcast was accepted into the machine (the answer this PR + // makes meaningful); publish it so the test asserts the accept, not just the servers' writes. + bool accepted = id(virtual_modbus_client)->queue_pdu(0x00, pdu); + id(broadcast_accepted).publish_state(accepted ? 1.0f : 0.0f); diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 057d419fd8..d0b375dd25 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -836,6 +836,44 @@ async def test_uart_mock_modbus_fairness( ) +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A client broadcast write (address 0) reaches every server and costs no timeout. + + The scenario button sends a broadcast single-register write of 777 to register + 0x10; both servers must apply it. The client's normal polling sensor must keep + updating, and no modbus warnings may appear - the pre-broadcast-support behavior + parked the frame in the waiting slot until the send-wait timeout, which surfaced + here as 'Stop waiting for response' warnings and a stalled poll. + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["reg_u_word", "srv1_written", "srv2_written", "broadcast_accepted"] + ) + poll_before = tracker.expect("reg_u_word", 919) + written = tracker.expect_all({"srv1_written": 777, "srv2_written": 777}) + # queue_pdu() must accept the broadcast into the machine (return true), the answer this PR adds. + accepted = tracker.expect("broadcast_accepted", 1) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_change(accepted, "broadcast_accepted") + await tracker.await_change(poll_before, "reg_u_word") + await tracker.await_all(written) + # Polling must continue after the broadcast (a burned timeout stalls it). + poll_after = tracker.expect("reg_u_word", 919) + await tracker.await_change(poll_after, "reg_u_word", timeout=3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_client_read_write( yaml_config: str, From f3a5a9fbd5ff1f1bb57f631654723065b9fe0a20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:41:55 -0500 Subject: [PATCH 094/597] [core] Retry transient git network failures with backoff (#18242) --- esphome/espidf/framework.py | 7 +- esphome/git.py | 181 ++++++- tests/unit_tests/test_espidf_framework.py | 12 + tests/unit_tests/test_git.py | 579 +++++++++++++++++++++- 4 files changed, 766 insertions(+), 13 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 39bf0465d5..0f6ef873b8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -458,11 +458,16 @@ def _clone_idf_with_submodules( key = f"{git_url}@{ref}" if ref else git_url _LOGGER.info("Cloning ESP-IDF from %s", key) - run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + run_git_command( + ["git", "clone", "--depth=1", "--", git_url, str(framework_path)], + network=True, + retry_cleanup=framework_path, + ) if ref: run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=framework_path, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], diff --git a/esphome/git.py b/esphome/git.py index d1dca3b3ae..9815377f51 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -5,6 +5,7 @@ from enum import Enum, auto import errno import hashlib import logging +import math import os from pathlib import Path import re @@ -77,6 +78,45 @@ _GIT_REPO_SCOPING_ENV = frozenset( } ) +# Substrings (matched case-insensitively against git's full stderr) that +# identify transient network failures worth retrying. Auth failures, +# missing repositories, and bad refs must fail immediately. Patterns are +# phrase-anchored so a repository URL quoted back in stderr never matches. +_TRANSIENT_GIT_ERROR_PATTERNS: tuple[str, ...] = ( + "unable to access", + "could not resolve host", + "could not connect", + "failed to connect", + "timed out", + "connection reset", + "connection refused", + "early eof", + "rpc failed", + "certificate verification failed", + # Anchored to curl's diagnostic prefix so repository URLs containing + # "ssl_" tokens never classify as transient + "openssl ssl_", + "ssl routines", + "ssl connect error", + "gnutls recv error", + "gnutls_handshake", + "unexpected disconnect", + "remote end hung up unexpectedly", +) + +# git quotes HTTP failures in two forms: curl's "The requested URL returned +# error: " and smart-HTTP's "RPC failed; HTTP curl ". 4xx is +# permanent (rejected credentials, missing repository) except 429 rate +# limiting; 408/425 are also treated as permanent, a deliberate trade for a +# simple rule since git hosts rarely emit them. +_PERMANENT_HTTP_ERROR_RE = re.compile(r"(?:http |returned error: )4(?!29)\d\d") + +# Network commands get 3 attempts with 2s/4s backoff. Worst case is ~3x +# the command's own duration plus 6s of sleep, held under the cache entry +# lock; peers with a complete entry fall back to it after +# _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS. +_NETWORK_MAX_ATTEMPTS = 3 + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -87,7 +127,18 @@ class GitNotInstalledError(GitException): class GitCommandError(GitException): - """Exception raised when a git command fails.""" + """Exception raised when a git command fails. + + ``stderr`` holds git's full stderr output; the exception message is + usually only the last ``fatal:`` line, but transient network markers + (``RPC failed``, ``GnuTLS``, ...) often appear on earlier lines. + Empty when git produced no stderr, so classification never reads the + command line (which embeds the user-supplied repository URL). + """ + + def __init__(self, message: str, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr class GitRepositoryError(GitException): @@ -103,8 +154,23 @@ def _redact_url_credentials(text: str) -> str: return re.sub(r"://[^/@\s]+@", "://***@", text) +def _is_transient_git_error(stderr: str) -> bool: + """Return True when git's stderr looks like a transient network failure.""" + lowered = stderr.lower() + if _PERMANENT_HTTP_ERROR_RE.search(lowered): + return False + if "authentication failed" in lowered: + return False + return any(pattern in lowered for pattern in _TRANSIENT_GIT_ERROR_PATTERNS) + + def run_git_command( - cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None + cmd: list[str], + git_dir: Path | None = None, + *, + cwd: Path | None = None, + network: bool = False, + retry_cleanup: Path | None = None, ) -> str: """Run a git command and return its stdout. @@ -113,7 +179,50 @@ def run_git_command( to that repository and runs the command there; ``cwd`` alone runs the command in that directory with GIT_CEILING_DIRECTORIES capping repository discovery at its parent. + + ``network=True`` marks a command that talks to a remote (clone, fetch, + submodule update): transient network failures (DNS, TLS, dropped + connections) are retried with a short backoff so a momentary blip does + not fail the whole build. Local-only commands must not set it. + ``retry_cleanup`` names a directory to remove before each retry, for + commands like clone that can leave a partial destination behind. """ + attempts = _NETWORK_MAX_ATTEMPTS if network else 1 + attempt = 0 + while True: + try: + return _run_git_command_once(cmd, git_dir, cwd=cwd) + except GitCommandError as err: + attempt += 1 + if attempt >= attempts or not _is_transient_git_error(err.stderr): + raise + if retry_cleanup is not None and retry_cleanup.is_dir(): + try: + rmtree(retry_cleanup) + except OSError as cleanup_err: + # A retry would fail on the leftover directory anyway; + # give up and keep the git error as the reported cause. + _LOGGER.warning( + "Could not remove %s before retry (%s); not retrying", + retry_cleanup, + cleanup_err, + ) + raise err from None + delay = 2**attempt + _LOGGER.warning( + "Git command failed: %s. Retrying in %d seconds... (attempt %d/%d)", + _redact_url_credentials(str(err)), + delay, + attempt, + attempts, + ) + time.sleep(delay) + + +def _run_git_command_once( + cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None +) -> str: + """Single attempt of ``run_git_command``; see its docstring.""" # Every invocation starts from an environment with the repository-scoping # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI # wrapper invoking ESPHome can never redirect these commands to its own @@ -168,11 +277,15 @@ def run_git_command( if ret.returncode != 0: if ret.stderr: - err_str = ret.stderr.decode("utf-8") + # errors="replace": git can emit locale-encoded (non-UTF-8) bytes + # in stderr; the error path must never raise UnicodeDecodeError. + err_str = ret.stderr.decode("utf-8", errors="replace") lines = [x.strip() for x in err_str.splitlines()] if lines[-1].startswith("fatal:"): - raise GitCommandError(lines[-1][len("fatal: ") :]) - raise GitCommandError(err_str) + raise GitCommandError(lines[-1][len("fatal: ") :], stderr=err_str) + raise GitCommandError(err_str, stderr=err_str) + # No stderr (e.g. git killed by a signal): nothing to classify, + # never retried. raise GitCommandError( f"git exited with code {ret.returncode}: " f"{_redact_url_credentials(' '.join(cmd))}" @@ -409,6 +522,7 @@ def update_submodules(repo_dir: Path, key: str) -> None: run_git_command( ["git", "submodule", "update", "--init", "--recursive", "--depth=1"], cwd=repo_dir, + network=True, ) @@ -605,7 +719,7 @@ def _clone_or_update_locked( try: cmd = ["git", "clone", "--depth=1"] cmd += ["--", url, str(repo_dir)] - run_git_command(cmd) + run_git_command(cmd, network=True, retry_cleanup=repo_dir) if ref is not None: # We need to fetch the PR branch first, otherwise git will complain @@ -614,6 +728,7 @@ def _clone_or_update_locked( run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=repo_dir, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir @@ -684,7 +799,57 @@ def _clone_or_update_locked( cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) - run_git_command(cmd, git_dir=repo_dir) + fetch_head = Path(repo_dir) / ".git" / "FETCH_HEAD" + try: + fetch_head_stat = fetch_head.stat() + except OSError: + # Missing (or unreadable): no pre-fetch FETCH_HEAD + fetch_head_stat = None + try: + run_git_command(cmd, git_dir=repo_dir, network=True) + except GitCommandError as err: + if not _is_transient_git_error(err.stderr): + raise + # Verified clone, untouched worktree, network-only + # failure: keep the clone instead of destroying it via + # recovery, which would re-clone on the same dead + # network. The marker must be restored or the next run + # removes the entry as an incomplete clone. + # + # A failed fetch still freshens FETCH_HEAD's mtime, + # which would suppress refresh attempts for the whole + # refresh window; restore it so the next run retries. + try: + if fetch_head_stat is not None: + os.utime( + fetch_head, + (fetch_head_stat.st_atime, fetch_head_stat.st_mtime), + ) + else: + fetch_head.unlink(missing_ok=True) + except OSError as stamp_err: + # Cannot keep the fallback honest; let the git error + # route through the recovery below instead. + _LOGGER.warning( + "Could not restore the refresh timestamp for %s (%s)", + safe_key, + stamp_err, + ) + raise err from None + _LOGGER.warning( + "Could not refresh %s (%s); using the existing clone " + "at %s (last updated %s ago)", + safe_key, + _redact_url_credentials(str(err)), + old_sha, + # age_seconds is inf when neither FETCH_HEAD nor HEAD + # could be stat'ed; format_duration would overflow + format_duration(age_seconds) + if math.isfinite(age_seconds) + else "unknown time", + ) + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) + return repo_dir, None # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) run_git_command( @@ -719,7 +884,7 @@ def _clone_or_update_locked( _LOGGER.warning( "Repository %s has issues (%s), attempting recovery", safe_key, - err, + _redact_url_credentials(str(err)), ) _LOGGER.info("Removing broken repository at %s", repo_dir) _remove_repo_dir(repo_dir) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 5912facbb3..d8e7738569 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -178,6 +178,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] assert not any(c[1] == "fetch" for c in calls) assert not any(c[1] == "reset" for c in calls) + # The clone must retry transient network failures and clean up a + # partial destination between attempts + clone_kwargs = run_git_command_mock.call_args_list[0].kwargs + assert clone_kwargs["network"] is True + assert clone_kwargs["retry_cleanup"] == framework_path def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: @@ -205,6 +210,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: ] assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + # Clone and fetch talk to the network and must carry the retry flag; + # the local reset must not + kwargs = [c.kwargs for c in run_git_command_mock.call_args_list] + assert kwargs[0]["network"] is True + assert kwargs[0]["retry_cleanup"] == framework_path + assert kwargs[1]["network"] is True + assert "network" not in kwargs[2] def test_clone_idf_with_submodules_raises_when_tree_missing( diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index ec1becf3e8..e296d48a46 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -247,6 +247,347 @@ def test_run_git_command_strips_fatal_prefix( assert "repository not found" in str(exc_info.value) +def _git_failure(stderr: bytes, returncode: int = 128) -> Mock: + """Build a failed subprocess.run result with the given stderr.""" + return Mock(returncode=returncode, stdout=b"", stderr=stderr) + + +_GIT_OK = Mock(returncode=0, stdout=b"ok", stderr=b"") + + +def test_run_git_command_network_retries_transient_then_succeeds( + mock_subprocess_run: Mock, +) -> None: + """A transient network failure is retried and the retry's result returned.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep") as mock_sleep: + result = git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + mock_sleep.assert_called_once_with(2) + + +def test_run_git_command_network_gives_up_after_max_attempts( + mock_subprocess_run: Mock, +) -> None: + """A persistent transient-looking failure raises after the final attempt.""" + mock_subprocess_run.side_effect = lambda *args, **kwargs: _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"server certificate verification failed. CAfile: none CRLfile: none\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="certificate verification failed"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 3 + assert [c.args[0] for c in mock_sleep.call_args_list] == [2, 4] + + +@pytest.mark.parametrize( + ("stderr", "transient"), + [ + # Transient: DNS, TLS, dropped connections, server-side errors + ("unable to access 'https://x/': The requested URL returned error: 502", True), + ("unable to access 'https://x/': Could not resolve host: github.com", True), + ("unable to access 'https://x/': Failed to connect: Timed out", True), + ("unable to access 'https://x/': Recv failure: Connection reset", True), + ("unable to access 'https://x/': Connection refused", True), + ("fatal: early EOF\nfatal: fetch-pack: invalid index-pack output", True), + ( + ( + "error: RPC failed; HTTP 500 curl 22 The requested URL returned " + "error: 500\nfatal: expected flush after ref listing" + ), + True, + ), + ( + ( + "unable to access 'https://x/': server certificate verification " + "failed. CAfile: none CRLfile: none" + ), + True, + ), + ( + ( + "error: RPC failed; curl 56 GnuTLS recv error (-110)\n" + "fatal: the remote end hung up unexpectedly" + ), + True, + ), + ( + ( + "fetch-pack: unexpected disconnect while reading sideband packet\n" + "fatal: early EOF" + ), + True, + ), + # 429 rate limiting is the one retryable 4xx, in both curl forms + ("unable to access 'https://x/': The requested URL returned error: 429", True), + ("error: RPC failed; HTTP 429 curl 22\nfatal: expected flush", True), + ( + ( + "unable to access 'https://x/': OpenSSL SSL_read: error:0A000126:" + "SSL routines::unexpected eof while reading, errno 0" + ), + True, + ), + # Permanent: missing repo, auth, bad ref, other 4xx + ("fatal: repository 'https://github.com/test/repo/' not found", False), + ( + ( + "fatal: could not read Username for 'https://github.com': " + "terminal prompts disabled" + ), + False, + ), + ("fatal: couldn't find remote ref refs/heads/nope", False), + ( + ( + "unable to access 'https://github.com/org/private.git/': " + "The requested URL returned error: 403" + ), + False, + ), + ("fatal: Authentication failed for 'https://github.com/test/repo/'", False), + # Smart-HTTP (HTTP/2) 4xx form has no "returned error:" text and + # mixes in transient-looking wording; still permanent + ( + ( + "error: RPC failed; HTTP 403 curl 92 HTTP/2 stream 5 was not " + "closed cleanly: CANCEL (err 8)\nfatal: expected flush after " + "ref listing" + ), + False, + ), + ( + ( + "error: RPC failed; HTTP 404 curl 22\n" + "fatal: the remote end hung up unexpectedly" + ), + False, + ), + ( + ( + "fatal: unable to access 'https://x/': gnutls_handshake() " + "failed: The TLS connection was non-properly terminated." + ), + True, + ), + # Transient-looking tokens in the URL must not classify as transient + ("fatal: repository 'https://github.com/x/esp32_ssl_reader/' not found", False), + ("fatal: repository 'https://gitlab.com/gnutls/gnutls.git/' not found", False), + ("", False), + ], +) +def test_is_transient_git_error(stderr: str, transient: bool) -> None: + """Real-world stderr outputs classify correctly as transient or permanent.""" + assert git._is_transient_git_error(stderr) is transient + + +def test_run_git_command_network_no_retry_on_permanent_error( + mock_subprocess_run: Mock, +) -> None: + """Permanent failures (missing repo, auth, bad ref) fail on the first try.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repository 'https://github.com/test/repo/' not found\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_no_retry_when_git_missing( + mock_subprocess_run: Mock, +) -> None: + """A missing git binary is not transient and must not be retried.""" + from esphome.git import GitNotInstalledError + + mock_subprocess_run.side_effect = FileNotFoundError("git not found") + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitNotInstalledError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_by_default(mock_subprocess_run: Mock) -> None: + """Without network=True even a transient-looking failure is not retried.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "status"]) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_retry_matches_full_stderr_not_last_line( + mock_subprocess_run: Mock, +) -> None: + """The transient marker often sits above the final fatal line; the retry + decision must look at the full stderr, not just the extracted message.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"error: RPC failed; curl 56 GnuTLS recv error (-54)\n" + b"fatal: fetch-pack: invalid index-pack output\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + + +def test_run_git_command_retry_warning_redacts_credentials( + mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """The retry warning embeds the git error, which embeds the URL; embedded + credentials must be redacted since warnings end up in pasted logs.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://user:hunter2@github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with ( + patch("esphome.git.time.sleep"), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + + +def test_run_git_command_clone_retry_removes_leftover_destination( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """A partial clone destination left by a failed attempt is removed before + the retry, so the retry cannot fail on 'destination path already exists'.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + (dest / "partial").write_text("x") + + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command( + [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/test/repo", + str(dest), + ], + network=True, + retry_cleanup=dest, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + assert not dest.exists() + + +def test_run_git_command_cleanup_failure_reraises_original_error( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """When the pre-retry cleanup fails, the git error stays the reported + cause instead of being replaced by the cleanup OSError.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.rmtree", side_effect=OSError("locked")), + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="Could not resolve host"), + ): + git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + retry_cleanup=dest, + ) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_on_empty_stderr_failure( + mock_subprocess_run: Mock, +) -> None: + """A failure with no stderr (e.g. git killed by a signal) is not retried.""" + mock_subprocess_run.return_value = _git_failure(b"", returncode=1) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="git exited with code 1"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_non_utf8_stderr_does_not_crash( + mock_subprocess_run: Mock, +) -> None: + """Locale-encoded (non-UTF-8) stderr must not raise UnicodeDecodeError.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repositorio no encontrado \xe9\xff\n" + ) + + with pytest.raises(GitCommandError, match="repositorio no encontrado"): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + + def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: """Test that run_git_command works without git_dir (clone case).""" # Configure mock to return success @@ -677,10 +1018,10 @@ def test_clone_or_update_with_none_refresh_always_updates( "ambiguous argument 'HEAD': unknown revision or path not in the working tree.", ), ("stash", "fatal: unable to write new index file"), - ( - "fetch", - "fatal: unable to access 'https://github.com/test/repo/': Could not resolve host", - ), + # The fetch failure must be non-transient: a transient one (e.g. + # "Could not resolve host") now keeps the existing clone instead of + # triggering recovery. + ("fetch", "fatal: couldn't find remote ref main"), ("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"), ], ) @@ -747,6 +1088,236 @@ def test_clone_or_update_recovers_from_git_failures( assert result_dir == repo_dir +@pytest.mark.parametrize("fetch_head_preexists", [True, False]) +def test_clone_or_update_transient_fetch_keeps_existing_clone( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + fetch_head_preexists: bool, +) -> None: + """A transient network failure while refreshing a verified clone falls back + to the existing clone instead of destroying it with a recovery re-clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + _setup_old_repo(repo_dir) + if not fetch_head_preexists: + # First-ever refresh: age comes from HEAD, FETCH_HEAD absent + (repo_dir / ".git" / "FETCH_HEAD").unlink() + head = repo_dir / ".git" / "HEAD" + head.write_text("test") + old_time = time.time() - 2 * 86400 + os.utime(head, (old_time, old_time)) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch": + # A failed fetch still freshens FETCH_HEAD, like real git + (repo_dir / ".git" / "FETCH_HEAD").touch() + stderr = ( + "fatal: unable to access " + "'https://user:hunter2@github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with caplog.at_level(logging.WARNING, logger="esphome.git"): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # The existing clone is returned, not removed or re-cloned + assert result_dir == repo_dir + assert repo_dir.is_dir() + assert revert is None + assert not any( + _get_git_command_type(c[0][0]) == "clone" + for c in mock_run_git_command.call_args_list + ) + # The completion marker must be restored, or the next run treats the + # entry as an incomplete clone and removes it + assert _marker_path(repo_dir).is_file() + # The warning must say what the build will actually use and how stale it is + assert "using the existing clone at abc123" in caplog.text + assert "ago" in caplog.text + # Credentials embedded in the URL must not reach the warning log + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + # The FETCH_HEAD the failed fetch freshened must not survive, or the + # refresh window would suppress retrying the update on subsequent runs + fetch_head = repo_dir / ".git" / "FETCH_HEAD" + if fetch_head_preexists: + assert time.time() - fetch_head.stat().st_mtime > refresh.total_seconds + else: + assert not fetch_head.exists() + + +def test_clone_or_update_timestamp_restore_failure_routes_to_recovery( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """If the FETCH_HEAD restore fails, the fallback cannot stay honest, so + the git error must route through recovery instead of a raw OSError.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + _setup_old_repo(repo_dir) + + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with ( + patch("esphome.git.os.utime", side_effect=OSError("read-only")), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + result_dir, _ = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + assert result_dir == repo_dir + assert "Could not restore the refresh timestamp" in caplog.text + # Recovery re-cloned rather than surfacing the OSError + assert call_counts.get("clone", 0) == 1 + + +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_clone_or_update_network_commands_carry_retry_flag( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """clone/fetch/submodule opt into transient-failure retry; local commands + (rev-parse, stash, reset) must not, so a refactor cannot silently drop or + widen the retry wiring.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, gitmodules=True + ) + else: + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + seen: set[str] = set() + for call in mock_run_git_command.call_args_list: + cmd_type = _get_git_command_type(call.args[0]) + seen.add(cmd_type) + if cmd_type in ("clone", "fetch", "submodule"): + assert call.kwargs.get("network") is True, cmd_type + else: + assert "network" not in call.kwargs, cmd_type + if cmd_type == "clone": + assert call.kwargs.get("retry_cleanup") == repo_dir + + expected = {"fetch", "reset", "submodule"} + expected |= {"clone"} if refresh is None else {"rev-parse", "stash"} + assert expected <= seen + + +def test_clone_or_update_transient_submodule_failure_still_recovers( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A transient failure after the reset (submodules) leaves a half-updated + tree, so it must route through recovery instead of keeping the clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "submodule" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/sub/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + result_dir, _ = git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + assert result_dir == repo_dir + # The half-updated tree must be recovered via re-clone, not kept + assert call_counts.get("clone", 0) == 1 + + def test_clone_or_update_fails_when_recovery_also_fails( tmp_path: Path, mock_run_git_command: Mock ) -> None: From aee41d64c2657ddec2c54ad5b66a6f4ce602edf9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 08:43:08 -0500 Subject: [PATCH 095/597] [rp2040_ble][bluetooth_connection] 3 connection slots on rp2 with esp32 parity (#18247) --- .../bluetooth_connection/__init__.py | 45 ++- .../bluetooth_connection_hub.cpp | 8 +- .../bluetooth_connection_hub.h | 5 +- .../bluetooth_connection_rp2.cpp | 324 +++++++++++++----- .../bluetooth_connection_rp2.h | 46 ++- .../components/bluetooth_proxy/__init__.py | 17 +- esphome/components/rp2040_ble/__init__.py | 68 +++- .../components/rp2040_ble/btstack_memory.cpp | 118 +++++++ esphome/core/defines.h | 6 +- .../bluetooth_proxy/test_platform_gates.py | 14 +- tests/component_tests/rp2040_ble/__init__.py | 0 .../rp2040_ble/config/rp2_proxy_default.yaml | 15 + .../config/rp2_proxy_single_slot.yaml | 16 + .../config/rp2_proxy_two_slots.yaml | 16 + .../rp2040_ble/test_connection_slots.py | 41 +++ .../rp2040_ble/test_pool_wrap.py | 52 +++ .../validate.rp2040-ard.yaml | 3 +- .../bluetooth_proxy/test.rp2040-ard.yaml | 4 +- .../bluetooth_proxy/test.rp2350-ard.yaml | 9 + .../build_components_base.rp2350-ard.yaml | 4 +- 20 files changed, 688 insertions(+), 123 deletions(-) create mode 100644 esphome/components/rp2040_ble/btstack_memory.cpp create mode 100644 tests/component_tests/rp2040_ble/__init__.py create mode 100644 tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml create mode 100644 tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml create mode 100644 tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml create mode 100644 tests/component_tests/rp2040_ble/test_connection_slots.py create mode 100644 tests/component_tests/rp2040_ble/test_pool_wrap.py create mode 100644 tests/components/bluetooth_proxy/test.rp2350-ard.yaml diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index ee46f85a38..fa1a86be3a 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import esphome.codegen as cg +from esphome.components import rp2040_ble from esphome.config_helpers import ( filter_source_files_from_platform, frameworks_for_platforms, @@ -36,9 +37,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") -# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1; -# raising this needs an upstream change (the layer itself supports N). -RP2_MAX_CONNECTIONS = 1 +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and +# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's +# btstack_memory.cpp replaces those pools via linker --wrap (requested by +# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself +# belongs to the platform stack that owns the pools. +RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS # Slot limits for the hub platforms running the connection-capable proxy; # the backend registry itself is _PLATFORM_BACKENDS below. @@ -53,6 +57,19 @@ BluedroidGattClient = bluetooth_connection_ns.class_( CONF_BACKEND_ID = "backend_id" +DOMAIN = "bluetooth_connection" + + +@dataclass +class _ConnectionData: + rp2_backend_count: int = 0 + + +def _get_data() -> _ConnectionData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _ConnectionData() + return CORE.data[DOMAIN] + def _esp32_schema_fragment() -> cv.Schema: from esphome.components import esp32_ble_tracker @@ -61,8 +78,6 @@ def _esp32_schema_fragment() -> cv.Schema: def _rp2_schema_fragment() -> cv.Schema: - from esphome.components import rp2040_ble - return cv.Schema( {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} ) @@ -77,15 +92,29 @@ async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble + from esphome.components import ota + # The backend drops its link when an OTA starts (esp32 tracker parity). + ota.request_ota_state_listeners() + # More than one backend outgrows the prebuilt BTstack pools: swap them for + # the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's + # btstack_memory.cpp. Keyed to backend registrations (the same event that + # grows the count that sizes the pools), so single-backend builds emit no + # flags and stay byte-identical to previous releases. + data = _get_data() + data.rp2_backend_count += 1 + if data.rp2_backend_count == 2: + rp2040_ble.add_btstack_pool_overrides() await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) @dataclass(frozen=True) class _PlatformBackend: - """One platform's backend: codegen class, extra schema keys (lazy so the - platform stack is only imported when targeted), and stack registration.""" + """One platform's backend: codegen class, extra schema keys, and stack + registration. The esp32 fragments import their stack lazily because those + imports register esp32-only automations as a side effect; rp2040_ble is + side-effect-free, so it is imported at module scope (the cap constant + needs it there anyway).""" backend_class: cg.MockObjClass schema_fragment: Callable[[], cv.Schema] diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 0b5d996349..c43b2a6f7c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -103,10 +103,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters. On esp32 the backend already set the - // same values as prefer-params before opening, so this request is - // usually redundant there - kept because rp2 has no prefer-params and - // the explicit update is its only path to the steady-state interval. + // shared steady-state parameters. Both backends already open cached + // connections with these values (esp32 prefer-params, rp2 initiating + // params), so this request is normally redundant - kept as a backstop + // in case the initial parameters were negotiated away. this->state_ = ClientState::ESTABLISHED; int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 783a8c466b..d964af5530 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -77,8 +77,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { bool connected() const { return this->state_ == ClientState::ESTABLISHED; } void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; - // The bluedroid backend branches on the type itself (prefer-params and - // the with-cache report at OPEN_EVT); the others ignore it. + // Both backends branch on the type before connecting (bluedroid picks + // prefer-params and the with-cache report at OPEN_EVT; rp2 picks the + // initiating parameters), so this must be set before the connect starts. this->backend_->set_connection_type(ct); } // Latched at discovery completion rather than read from the backend table: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dea3b5d9c8..dc8fb6714b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -26,6 +26,13 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +// Budget after a cancel is in flight: its completion normally lands within +// tens of ms, and while the engine waits it pins the stack-wide connect slot, +// so a lost completion must cost seconds, not another full connect budget. +static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000; +// Pending engines re-attempt gap_connect on this cadence instead of every +// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock. +static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -54,6 +61,7 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; +RP2GattClient *RP2GattClient::connect_owner = nullptr; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -84,6 +92,7 @@ void RP2GattClient::setup() { // One locked section: the slot store lands before the count bump, and a // live HCI handler (N > 1 builds) cannot read a half-written registry. BluetoothLock lock; + this->engine_index_ = instance_count; instances[instance_count] = this; instance_count++; // One HCI event handler for all engine instances (BTstack supports @@ -96,9 +105,24 @@ void RP2GattClient::setup() { } } +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + this->disable_loop(); } +#ifdef USE_OTA_STATE_LISTENER +void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + // esp32 parity (its tracker disconnects every client at OTA start): free + // the shared radio for the transfer. No restore needed; the client + // reconnects, and on success the device reboots anyway. + if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) { + this->gatt_disconnect(); + } +} +#endif + float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } @@ -124,34 +148,56 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { break; } - bd_addr_t peer; - gap_subevent_le_connection_complete_get_peer_address(packet, peer); uint8_t status = gap_subevent_le_connection_complete_get_status(packet); hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); - // Route to the engine that is waiting for this peer. - for (uint8_t i = 0; i < instance_count; i++) { - RP2GattClient *inst = instances[i]; - if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) { - inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); - break; + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + // Route by ownership, not address: gap_connect refuses a new + // create-connection until the previous completion is processed, so the + // event belongs to the owner by construction. Cancel completions carry + // a zeroed peer address on this controller, so an address match would + // drop them and pin the owner until its backstop. + RP2GattClient *inst = connect_owner; + static constexpr bd_addr_t ZERO_ADDR = {}; + if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 && + memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) { + // Addressed completion for a peer the owner is not connecting to: a + // success delayed past a cancel and an ownership handoff (the cancel + // idles the stack's request immediately) must not stamp the old + // procedure's link onto the new owner. Zero-address (cancel) + // completions need no such guard: BTstack only emits them while its + // request state is idle, and a new owner re-arms that state when it + // claims the token, so a stale cancel completion is swallowed by the + // stack, never re-attributed. A successful stale link still needs + // disposal (same hazard as the unowned branch below). + if (status == 0) { + gap_disconnect(con_handle); } + break; } + connect_owner = nullptr; + if (inst == nullptr) { + if (status == 0) { + // Nobody owns this late link (the owner escalated first): tear it + // down here or the hci_connection_t leaks and the peer answers + // DISALLOWED until reboot. + gap_disconnect(con_handle); + } + break; + } + if (status == 0) { + // Stamp the handle here in the BTstack context: a disconnection + // racing the queued CONNECTED event arrives in this same context + // and must route by handle (it carries no address). + inst->con_handle_ = con_handle; + } + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); break; } case HCI_EVENT_DISCONNECTION_COMPLETE: { - hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet); - RP2GattClient *inst = instance_for_con_handle(con_handle); - if (inst == nullptr && instance_count == 1) { - // The main loop may not have recorded the handle yet (the CONNECTED - // event is still queued); with a single engine the connecting - // instance is unambiguous, so route there to close the - // accept-then-drop window. With multiple engines the event has no - // address to match on, so it must be dropped instead of guessed. - RP2GattClient *candidate = instances[0]; - if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) { - inst = candidate; - } - } + // Routable even against a still-queued CONNECTED event: the handle is + // stamped in this context at connection-complete time. + RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet)); if (inst != nullptr) { inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); } @@ -393,44 +439,73 @@ void RP2GattClient::loop() { if (dropped > 0) { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. - ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); + ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped); this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped); + ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped); } - if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + if (this->state_ == EngineState::CONNECT_PENDING) { uint32_t now = millis(); if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Connect timeout"); - if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) { - if (!this->connect_cancel_attempted_) { - this->connect_cancel_attempted_ = true; - BluetoothLock lock; + // Never reached the radio; nothing stack-side to cancel. + ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_); + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) { + this->connect_retry_ms_ = now; + if (int err = this->try_gap_connect_(); err != 0) { + this->fail_connection_(static_cast(err)); + } + } + } else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID && + this->connect_cancel_attempted_; + uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS; + if (now - this->connect_started_ > budget) { + ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_); + bool link_up = this->state_ != EngineState::CONNECTING; + bool cancel_sent = false; + if (!link_up) { + BluetoothLock lock; + // Handle check under the lock: a success completion can stamp it in + // the BTstack context right up to this point, and escalating past a + // live link would orphan it (the queued CONNECTED event is dropped + // by the state guard once fail_connection_ runs). + link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID; + if (!link_up && connect_owner == this) { + // gap_connect_cancel is stack-global; only the engine whose + // create-connection is in flight may issue it. First timeout: + // cancel and give the completion a grace period. Second: the + // completion was lost, re-issue the cancel in case the procedure + // still runs (a no-op on an idle stack), then escalate. gap_connect_cancel(); - // The cancel produces a connection-complete event with a failure - // status, which drives the normal failure path; restart the timer - // so a lost event escalates below instead of wedging here. - this->connect_started_ = now; - } else { - // The cancel's completion never arrived: reclaim the slot and the - // scan rather than cancelling forever. - this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + cancel_sent = !this->connect_cancel_attempted_; } - } else { - // The link is up (MTU exchange stalled): tear it down properly so the - // controller frees its side; the DISCONNECTING safety net below - // reclaims state if the disconnection event is lost. Dropping engine - // state without gap_disconnect would leak the live link and the - // single GATT slot for the rest of the boot. + this->connect_cancel_attempted_ = true; + } + if (link_up) { + // The link is up (stamped mid-timeout or MTU exchange stalled): tear + // it down properly so the controller frees its side; the + // DISCONNECTING safety net below reclaims state if the disconnection + // event is lost. Dropping engine state without gap_disconnect would + // leak the live link and this engine's GATT slot for the rest of the + // boot. this->gatt_disconnect(); + } else if (cancel_sent) { + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer so + // a lost event escalates on the short cancel budget. + this->connect_started_ = now; + } else { + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); } } } else if (this->state_ == EngineState::DISCONNECTING) { if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); + ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && @@ -446,7 +521,7 @@ void RP2GattClient::loop() { } } if (timed_out) { - ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); + ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_); this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && @@ -467,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::MTU_EXCHANGED: if (this->state_ == EngineState::MTU_EXCHANGE) { this->mtu_ = event.value; - ESP_LOGD(TAG, "MTU %u", this->mtu_); + ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); @@ -515,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { return; } if (status != 0) { - ESP_LOGW(TAG, "Connect failed, status=0x%02x", status); + ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status); this->fail_connection_(status); return; } @@ -539,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { } this->con_handle_ = con_handle; this->state_ = EngineState::MTU_EXCHANGE; - ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle); + ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); BluetoothLock lock; // One wildcard listener covers notifications/indications for every // characteristic on this connection; the CCCD writes come from the API @@ -564,6 +639,24 @@ void RP2GattClient::release_scan_inhibit_() { } void RP2GattClient::fail_connection_(uint8_t reason) { + { + // Timeout escalation can fire with the completion event lost; release the + // stack-wide connect slot so pending engines can proceed. Until the old + // completion is processed, gap_connect answers any peer with DISALLOWED + // (the request-level guard in hci.c); a cancel idles that request + // immediately, and a late addressed completion from the old procedure is + // then dropped by the owner-peer cross-check in the handler. + BluetoothLock lock; + if (connect_owner == this) { + connect_owner = nullptr; + } + if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // A success completion stamped the handle between the escalation + // decision and this lock: tear the link down before cleanup wipes the + // handle, or it leaks its pool block for the rest of the boot. + gap_disconnect(this->con_handle_); + } + } this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; @@ -577,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() { while ((stale = this->notify_queue_.pop()) != nullptr) { this->notify_pool_.release(stale); } - // The wildcard listener is registered on the normal connect path right - // after con_handle_ is recorded; the cancel branch tears down before - // registering, where stop_listening on an unregistered entry is a no-op. - if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // con_handle_ may be stamped in the BTstack context before the main loop + // registers the listener, so a valid handle does not imply a registration; + // stop_listening on an unregistered entry is a benign no-op. One lock + // scope around check and reset so an IRQ stamp cannot land in between + // (unreachable today — ownership is released before cleanup — but the + // invariant lives three functions away). + { BluetoothLock lock; - gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; } - this->con_handle_ = HCI_CON_HANDLE_INVALID; this->notify_subscription_count_ = 0; this->cancel_requested_ = false; this->op_type_ = OpType::NONE; @@ -596,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) { if (this->state_ == EngineState::IDLE) { return; } - ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason); + ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); this->fail_connection_(reason); } @@ -654,7 +752,7 @@ int RP2GattClient::discover_services() { RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); this->arena_ = allocator.allocate(1); if (this->arena_ == nullptr) { - ESP_LOGE(TAG, "Service table allocation failed"); + ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_); return ble_device_base::GATT_ERR_NO_MEMORY; } new (this->arena_) ServiceArena(); @@ -760,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) { void RP2GattClient::finish_discovery_(int error) { this->discovery_phase_ = DiscoveryPhase::NONE; - ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_, - this->char_count_, this->desc_count_); + ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + error, this->service_count_, this->char_count_, this->desc_count_); if (error == 0 && this->truncated_) { // A partial table must not stream: V3 clients cache the database // permanently, so an incomplete one would be wrong forever. @@ -839,22 +937,68 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { this->parent_->inhibit_scan(); this->connect_cancel_attempted_ = false; this->cancel_requested_ = false; + // Bounds the queued wait; restarted when gap_connect is accepted so the + // radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the + // sum via a disconnect request). + this->connect_started_ = millis(); + if (int err = this->try_gap_connect_(); err != 0) { + this->release_scan_inhibit_(); + return err; + } + this->enable_loop(); + return 0; +} + +// One outgoing LE create-connection exists stack-wide: issue it if no other +// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry. +// Returns nonzero only for hard failures (state untouched; caller cleans up). +int RP2GattClient::try_gap_connect_() { + // Unlocked peek: single core, aligned pointer; a stale value costs one loop + // pass and the locked re-check below is authoritative. Keeps the per-loop + // pending retry from taking BluetoothLock just to find the radio busy. + if (connect_owner != nullptr) { + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } uint8_t status; { BluetoothLock lock; - gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, - 0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); - status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (connect_owner != nullptr) { + status = ERROR_CODE_COMMAND_DISALLOWED; + } else { + // esp32 parity: cached connections come up at MEDIUM already (nothing + // consumes the fast interval without a discovery phase), so there is no + // post-connect update procedure to race or silently lose; sustained + // FAST intervals also starve WiFi on the shared CYW43 radio. + // Without-cache runs FAST for discovery and steps down in + // finish_discovery_. + bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, + cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL, + cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0, + cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (status == 0) { + connect_owner = this; + // Still under the lock: a synthesized failure completion can fire in + // the BTstack context the instant it releases, and completion routing + // requires CONNECTING — set after the fact, the event is discarded + // and the engine burns its whole budget waiting for it. + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + } + } } - if (status != 0) { - ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status); - this->release_scan_inhibit_(); - return status; + if (status == 0) { + return 0; } - this->state_ = EngineState::CONNECTING; - this->connect_started_ = millis(); - this->enable_loop(); - return 0; + if (status == ERROR_CODE_COMMAND_DISALLOWED) { + // Radio busy with another engine's connect; resolved from loop(). + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status); + return status; } int RP2GattClient::gatt_disconnect() { @@ -863,6 +1007,10 @@ int RP2GattClient::gatt_disconnect() { return GATT_ERR_NOT_CONNECTED; case EngineState::DISCONNECTING: return 0; // already on its way down + case EngineState::CONNECT_PENDING: + // Nothing issued stack-side; the invalid handle takes the refused + // path below without touching the stack. + break; case EngineState::CONNECTING: { if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { // The cancel can lose the race against a successful connection @@ -871,9 +1019,18 @@ int RP2GattClient::gatt_disconnect() { // attempt, so a lost completion escalates on the next timeout tick. this->cancel_requested_ = true; this->connect_cancel_attempted_ = true; + // Grace period for the cancel completion: the client's disconnect + // often lands right at the engine's own deadline, and without the + // restart the loop timeout fires first and reports before the + // completion can finish the teardown cleanly. + this->connect_started_ = millis(); BluetoothLock lock; - gap_connect_cancel(); - // Completion arrives as a failed connection-complete event. + // Owner: the cancel completes as a failed connection-complete. Not + // the owner (completion already resolved in the BTstack context): the + // queued event drives the same teardown, nothing to cancel. + if (connect_owner == this) { + gap_connect_cancel(); + } return 0; } break; @@ -881,20 +1038,23 @@ int RP2GattClient::gatt_disconnect() { default: break; } - uint8_t status; - { - BluetoothLock lock; - status = gap_disconnect(this->con_handle_); - } - if (status != 0) { - // Refused (handle already gone): complete via the event queue so the - // listener cannot re-enter disconnect() mid-call. BluetoothLock stops - // the IRQ producer, so this main-loop push is SPSC-safe. - ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status); + uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { { BluetoothLock lock; - this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + status = gap_disconnect(this->con_handle_); } + if (status != 0) { + ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status); + } + } + if (status != 0) { + // Refused (handle already gone) or never issued (CONNECT_PENDING): + // complete via the event queue so the listener cannot re-enter + // disconnect mid-call. BluetoothLock stops the IRQ producer, so this + // main-loop push is SPSC-safe. + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); } this->state_ = EngineState::DISCONNECTING; this->disconnecting_started_ = millis(); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index df43ebd66d..4d407269b6 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -19,6 +19,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include @@ -71,7 +75,13 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; // full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; -class RP2GattClient final : public Component, public Parented { +class RP2GattClient final : public Component, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: void setup() override; void loop() override; @@ -95,18 +105,26 @@ class RP2GattClient final : public Component, public Parentedconnection_type_ = ct; } void release_services(); +#ifdef USE_OTA_STATE_LISTENER + // Drop the connection while an OTA runs (esp32 parity): an active link + // competes with the transfer for the shared radio. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + protected: // Link/engine state. Discovery and GATT ops have their own cursors below — // the link stays READY while they run. enum class EngineState : uint8_t { IDLE, - CONNECTING, // gap_connect issued, waiting for connection complete - MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU - READY, // on_connection_state(true) delivered + CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered DISCONNECTING, }; @@ -143,6 +161,7 @@ class RP2GattClient final : public Component, public Parented notify_subscriptions_{}; uint8_t notify_subscription_count_{0}; - bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects - bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE}; EngineState state_{EngineState::IDLE}; DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; OpType op_type_{OpType::NONE}; @@ -214,6 +238,12 @@ class RP2GattClient final : public Component, public Parented ConfigType: @functools.cache def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack - GATT client backend in bluetooth_connection. The slot limit comes from the - prebuilt BTstack library (one connection today); the code is built for N.""" + GATT client backend in bluetooth_connection. Multi-slot builds replace the + prebuilt library's one-client BTstack pools via linker --wrap, owned by + rp2040_ble and requested when a second backend registers.""" connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) def populate_connections(config: ConfigType) -> ConfigType: + from esphome.components import rp2040_ble + # One wrapper + backend pair per slot, declared during validation so # their ids exist for codegen (the esp32 arm's `connections` pattern). if not config[CONF_ACTIVE]: return config + connection_slots: int = config[CONF_CONNECTION_SLOTS] + rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) return { **config, - CONF_CONNECTIONS: [ - connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS]) - ], + CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)], } max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] @@ -182,8 +185,8 @@ def _rp2_config_schema() -> cv.All: min=1, max=max_conn, msg=f"rp2 supports at most {max_conn} connection slot(s); " - "the framework's BTstack library is built with " - f"MAX_NR_GATT_CLIENTS {max_conn}", + "the BTstack pool overrides in rp2040_ble are sized " + f"for {max_conn}", ), ), } diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index e49dceb000..332ea73a61 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -1,6 +1,9 @@ +from collections.abc import Callable, MutableMapping + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE from esphome.types import ConfigType DEPENDENCIES = ["rp2"] @@ -8,6 +11,15 @@ CODEOWNERS = ["@bdraco"] CONF_RP2040_BLE_ID = "rp2040_ble_id" +KEY_RP2040_BLE = "rp2040_ble" +KEY_USED_CONNECTION_SLOTS = "used_connection_slots" + +# Hard platform cap on concurrent GATT connections: the BTstack pool overrides +# in btstack_memory.cpp are sized from ESPHOME_BLE_GATT_CLIENT_COUNT with this +# as the ceiling. 3 matches the esp32 default and stays within the +# controller's resources (MAX_NR_CONTROLLER_ACL_BUFFERS 3). +MAX_CONNECTIONS = 3 + rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) @@ -30,13 +42,67 @@ def _validate_board(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = _validate_board +def consume_connection_slots( + value: int, consumer: str +) -> Callable[[MutableMapping], MutableMapping]: + """Reserve BLE connection slots for a component (the esp32_ble pattern); + the total is checked against MAX_CONNECTIONS in final validation.""" + + def _consume_connection_slots(config: MutableMapping) -> MutableMapping: + data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {}) + slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) + slots.extend([consumer] * value) + return config + + return _consume_connection_slots + + +def validate_connection_slots() -> None: + """Fail when consumers claimed more slots than the platform cap.""" + # Skip in testing mode to allow component grouping (esp32_ble parity). + if CORE.testing_mode: + return + used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, []) + if len(used) > MAX_CONNECTIONS: + raise cv.Invalid( + f"BLE components require {len(used)} connection slots but the " + f"rp2 maximum is {MAX_CONNECTIONS}. " + f"Components: {', '.join(used)}" + ) + + +def _final_validate(config: ConfigType) -> ConfigType: + _validate_board(config) + validate_connection_slots() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate # Once per registered scan listener; sizes the controller's StaticVector # listener storage. request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT") +# The four btstack_memory accessors whose static pools are baked into the +# prebuilt liblwip-bt.a; every internal use crosses an object boundary in the +# archive, so --wrap intercepts them all (see btstack_memory.cpp). +_BTSTACK_POOL_SYMBOLS = ( + "btstack_memory_gatt_client_get", + "btstack_memory_gatt_client_free", + "btstack_memory_hci_connection_get", + "btstack_memory_hci_connection_free", +) + + +def add_btstack_pool_overrides() -> None: + """Emit the --wrap flags that swap the prebuilt BTstack pools for the + ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in btstack_memory.cpp. Called by + bluetooth_connection when a second GATT backend registers; idempotent + (build flags are a set).""" + for symbol in _BTSTACK_POOL_SYMBOLS: + cg.add_build_flag(f"-Wl,--wrap={symbol}") + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp new file mode 100644 index 0000000000..8af57924a2 --- /dev/null +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -0,0 +1,118 @@ +// Replaces the gatt_client / hci_connection static pools baked into +// arduino-pico's prebuilt liblwip-bt.a (built with MAX_NR_GATT_CLIENTS 1, +// MAX_NR_HCI_CONNECTIONS 2) with pools sized from ESPHOME_BLE_GATT_CLIENT_COUNT. +// add_btstack_pool_overrides() in this component's codegen emits the matching +// -Wl,--wrap flags, requested by bluetooth_connection when more than one GATT +// backend registers; single-backend builds emit no flags and this file +// compiles to nothing, leaving the prebuilt pools in charge. Layout safety: +// the framework defines ENABLE_CLASSIC / ENABLE_BLE for every user TU +// whenever PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH is set (this component +// always sets it), so sizeof() here matches the archive. + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) && (ESPHOME_BLE_GATT_CLIENT_COUNT > 1) + +#include + +#include + +namespace esphome::rp2040_ble { +namespace { + +// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// a changed ENABLE_* macro) shifting the struct layout must fail the build +// here, not overrun the pool blocks at runtime. Sizes differ per core +// architecture (measured from each archive's own storage symbols). GCC only: +// the clang-tidy frontend lays these structs out differently, and the guard +// targets the real link. +#ifndef __clang__ +#ifdef __riscv +static_assert(sizeof(gatt_client_t) == 140 && sizeof(hci_connection_t) == 3740, "BTstack layout changed"); +#else +static_assert(sizeof(gatt_client_t) == 128 && sizeof(hci_connection_t) == 3688, "BTstack layout changed"); +#endif +#endif // __clang__ + +// One gatt_client_t per configured connection slot. An hci_connection_t is +// held from gap_connect() to DISCONNECTION_COMPLETE (scanning holds none); +// +1 mirrors the prebuilt library's own headroom (2 connections for 1 GATT +// client) so a teardown/re-connect overlap can never starve a slot. +constexpr int HCI_CONNECTION_POOL_SIZE = ESPHOME_BLE_GATT_CLIENT_COUNT + 1; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) +gatt_client_t gatt_client_storage[ESPHOME_BLE_GATT_CLIENT_COUNT]; +btstack_memory_pool_t gatt_client_pool; +hci_connection_t hci_connection_storage[HCI_CONNECTION_POOL_SIZE]; +btstack_memory_pool_t hci_connection_pool; + +// Static init: pool_create only links a free list through its own storage, +// and BTstack first allocates long after static construction. +struct PoolInit { + PoolInit() { + btstack_memory_pool_create(&gatt_client_pool, gatt_client_storage, ESPHOME_BLE_GATT_CLIENT_COUNT, + sizeof(gatt_client_t)); + btstack_memory_pool_create(&hci_connection_pool, hci_connection_storage, HCI_CONNECTION_POOL_SIZE, + sizeof(hci_connection_t)); + } +} pool_init; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) + +} // namespace + +// Exact semantics of btstack_memory.c's static-pool arm: zeroed block on +// success, NULL when exhausted; free returns the block to the pool. The +// prebuilt pools stay resident in .bss (~7.4 KB, kept live by +// btstack_memory_init in the archive) — dead weight here, not a leak. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" gatt_client_t *__real_btstack_memory_gatt_client_get(void); +extern "C" void __real_btstack_memory_gatt_client_free(gatt_client_t *gatt_client); +extern "C" hci_connection_t *__real_btstack_memory_hci_connection_get(void); +extern "C" void __real_btstack_memory_hci_connection_free(hci_connection_t *hci_connection); + +namespace { +// Fails the link if the corresponding --wrap flag is missing: __real_* only +// exists while --wrap is in effect, and each wrap function anchors its own +// symbol so dropping any single flag fails loudly. A code reference is used +// because the framework links with --gc-sections, which discards an +// unreferenced data anchor regardless of [[gnu::used]] (and this toolchain +// does not emit SHF_GNU_RETAIN for [[gnu::retain]]). +template void anchor_wrap(T *symbol) { asm volatile("" ::"r"(symbol)); } +} // namespace + +extern "C" { + +gatt_client_t *__wrap_btstack_memory_gatt_client_get(void) { + anchor_wrap(&__real_btstack_memory_gatt_client_get); + void *buffer = btstack_memory_pool_get(&gatt_client_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(gatt_client_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_gatt_client_free(gatt_client_t *gatt_client) { + anchor_wrap(&__real_btstack_memory_gatt_client_free); + btstack_memory_pool_free(&gatt_client_pool, gatt_client); +} + +hci_connection_t *__wrap_btstack_memory_hci_connection_get(void) { + anchor_wrap(&__real_btstack_memory_hci_connection_get); + void *buffer = btstack_memory_pool_get(&hci_connection_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(hci_connection_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_hci_connection_free(hci_connection_t *hci_connection) { + anchor_wrap(&__real_btstack_memory_hci_connection_free); + btstack_memory_pool_free(&hci_connection_pool, hci_connection); +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT && ESPHOME_BLE_GATT_CLIENT_COUNT > 1 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 319018a36f..21cea31749 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -262,13 +262,13 @@ #define USE_BLUETOOTH_PROXY // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits -// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis +// the slot count (3 on rp2, 0 on advertisement-only hubs) — so static analysis // checks the same instantiations a real build produces. #ifdef USE_ESP32 #define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #elif defined(USE_RP2) -#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif @@ -482,7 +482,7 @@ #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT -#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 +#define ESPHOME_BLE_GATT_CLIENT_COUNT 3 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 16a3850d46..8fc7ffd23b 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -142,8 +142,8 @@ def test_rp2_defaults_to_the_full_proxy( _register_tracker(PLATFORM_RP2) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is True - assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 1 - assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 + assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3 + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3 def test_rp2_accepts_explicit_passive( @@ -159,11 +159,15 @@ def test_rp2_accepts_explicit_passive( def test_rp2_rejects_slots_beyond_the_btstack_limit( set_core_config: SetCoreConfigCallable, ) -> None: - # The prebuilt BTstack library allows exactly one GATT client connection. + # The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots. set_core_config(PlatformFramework.RP2_ARDUINO) _register_tracker(PLATFORM_RP2) - with pytest.raises(cv.Invalid, match="at most 1 connection slot"): - bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + with pytest.raises(cv.Invalid, match="at most 3 connection slot"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4}) + # Fewer slots than the cap stay accepted (the prebuilt single-client pool + # path for 1, the wrap path for 2). + validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1}) + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 # Values past even the loosest platform cap stop at the outer walkable # schema, which stays bounded for range walkers (device-builder sync); # in-range values get the platform message above. diff --git a/tests/component_tests/rp2040_ble/__init__.py b/tests/component_tests/rp2040_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml new file mode 100644 index 0000000000..93c769283f --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml @@ -0,0 +1,15 @@ +esphome: + name: poolwrap-rp2-default + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml new file mode 100644 index 0000000000..4e9c94df59 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-single + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 1 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml new file mode 100644 index 0000000000..c631562743 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-two + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 2 diff --git a/tests/component_tests/rp2040_ble/test_connection_slots.py b/tests/component_tests/rp2040_ble/test_connection_slots.py new file mode 100644 index 0000000000..f33180e2d0 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_connection_slots.py @@ -0,0 +1,41 @@ +"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and +final validation rejects over-subscription with the consumer list.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import rp2040_ble +from esphome.core import CORE + + +def test_proxy_claims_its_slots_through_the_shared_accounting( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # A default (3-slot) proxy build records one claim per slot, attributed + # to the consumer, and passes final validation. + generate_main(component_config_path("rp2_proxy_default.yaml")) + used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS] + assert used == ["bluetooth_proxy"] * 3 + + +def test_oversubscription_is_rejected_with_the_consumer_list() -> None: + # No YAML shape reaches this today (the proxy schema caps at the same + # limit); the guard exists for a second consumer such as ble_client. + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.consume_connection_slots(1, "ble_client")({}) + with pytest.raises( + cv.Invalid, + match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client", + ): + rp2040_ble.validate_connection_slots() + + +def test_at_cap_passes() -> None: + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.validate_connection_slots() diff --git a/tests/component_tests/rp2040_ble/test_pool_wrap.py b/tests/component_tests/rp2040_ble/test_pool_wrap.py new file mode 100644 index 0000000000..291ca5eb58 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_pool_wrap.py @@ -0,0 +1,52 @@ +"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags +that swap the prebuilt single-client pools for the codegen-sized ones; +single-slot builds emit none and stay byte-identical to previous releases.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +from ..helpers import get_define_value + +# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo +# in the component's list fails here instead of mirroring into the test. +WRAP_FLAGS = ( + "-Wl,--wrap=btstack_memory_gatt_client_get", + "-Wl,--wrap=btstack_memory_gatt_client_free", + "-Wl,--wrap=btstack_memory_hci_connection_get", + "-Wl,--wrap=btstack_memory_hci_connection_free", +) + + +def test_default_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_default.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3" + + +def test_two_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # Two slots: the wrap pools are smaller than the cap, sized from the count. + generate_main(component_config_path("rp2_proxy_two_slots.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2" + + +def test_single_slot_keeps_the_prebuilt_pools( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_single_slot.yaml")) + assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1" diff --git a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml index 620aaa177b..d3674b8406 100644 --- a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml +++ b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml @@ -6,6 +6,7 @@ packages: rp2_ble_tracker: +# Two slots: the one shape where the wrap pools are smaller than the cap. bluetooth_proxy: active: true - connection_slots: 1 + connection_slots: 2 diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml index e219c7542d..77ed2ea32d 100644 --- a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -1,5 +1,7 @@ # Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity), -# so this compiles the BTstack GATT client backend and one connection slot. +# so this compiles the BTstack GATT client backend with the default three +# connection slots, exercising the rp2040_ble/btstack_memory.cpp pool --wrap +# link. # No explicit ble_hub_id: the generated binding resolves the single declared # hub, and an inline id here would collide with rp2_ble_tracker's own fixture # once CI merges both components into one grouped rp2040-ard build (grouped diff --git a/tests/components/bluetooth_proxy/test.rp2350-ard.yaml b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml new file mode 100644 index 0000000000..1abc62cedb --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml @@ -0,0 +1,9 @@ +# Pico 2 W build of the full proxy: links the rp2350 framework archive, so +# the pool --wrap overrides and their per-architecture layout asserts are +# exercised for this chip too (see test.rp2040-ard.yaml for the slot shape). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/test_build_components/build_components_base.rp2350-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml index 5df1670862..f76c5fc3f9 100644 --- a/tests/test_build_components/build_components_base.rp2350-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,8 +2,10 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name +# rpipico2w: superset of rpipico2 with the CYW43 radio, so wireless +# components (wifi, BLE) can share this target too. rp2: - board: rpipico2 + board: rpipico2w logger: level: VERY_VERBOSE From 9938a2487dfab22a2e2668302c9a5e44b19e5f6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 10:55:42 -0500 Subject: [PATCH 096/597] [core] Show the last line when output stops without a newline (#18265) --- esphome/espidf/runner.py | 67 +++++- esphome/platformio/runner.py | 18 +- esphome/util.py | 83 ++++++-- .../fixtures/espidf/closing_probe.py | 11 + .../fixtures/espidf/crashing_probe.py | 11 + .../fixtures/espidf/partial_noise_probe.py | 10 + tests/unit_tests/test_espidf_runner.py | 81 ++++++- tests/unit_tests/test_platformio_runner.py | 93 ++++++++ tests/unit_tests/test_util.py | 198 ++++++++++++++++++ 9 files changed, 532 insertions(+), 40 deletions(-) create mode 100644 tests/unit_tests/fixtures/espidf/closing_probe.py create mode 100644 tests/unit_tests/fixtures/espidf/crashing_probe.py create mode 100644 tests/unit_tests/fixtures/espidf/partial_noise_probe.py create mode 100644 tests/unit_tests/test_platformio_runner.py diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 9e1f24d5ed..298a21fb82 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -90,6 +90,7 @@ def main() -> int: sys.path.pop(0) # ---- end sys.path fix-up ----------------------------------------------- + import contextlib import os from pathlib import Path import re @@ -179,6 +180,44 @@ def main() -> int: def flush(self) -> None: self._stream.flush() + def _emit(self, line: str) -> None: + if self._filter_pattern is not None: + stripped = ansi_escape.sub("", line).rstrip() + if self._filter_pattern.match(stripped) is not None: + return + self._stream.write(line) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + idf.py and CMake do not always end their last line with a + newline, and a build that dies part way through can stop mid + line. Without this the user is left staring at a build that + ended with no explanation. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit(line + "\n") + self._stream.flush() + except (OSError, ValueError) as err: + # We are called from cleanup, so raising would replace the + # build's real exit code. Saying so must not raise either: + # under the dashboard our stdout and stderr are the same + # pipe, so whatever broke the write has most likely broken + # the report, and ``sys.__stderr__`` is None on some + # interpreters. Carry the line along; it is usually the + # message saying why the build failed. + if (real_stderr := sys.__stderr__) is not None: + with contextlib.suppress(OSError, ValueError): + print( + f"Could not write out remaining output ({err}): {line}", + file=real_stderr, + ) + def write(self, data) -> int: # Text streams normally hand us ``str``; decode in case # somebody writes bytes directly. @@ -186,7 +225,8 @@ def main() -> int: data = data.decode(errors="replace") if self._filter_pattern is None: - self._stream.write(data) + # Nothing to match against, so no need to wait for a full line. + self._emit(data) else: self._line_buffer += data for line in self._line_buffer.splitlines(keepends=True): @@ -195,11 +235,7 @@ def main() -> int: self._line_buffer = line break self._line_buffer = "" - - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - continue - self._stream.write(line) + self._emit(line) # We tell idf.py it is talking to a terminal, so it sends progress # bars and cursor moves. Our own stdout is usually a pipe, which is @@ -222,8 +258,8 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] + stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -241,8 +277,19 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. - runpy.run_path(script_path, run_name="__main__") + # fall through and exit with 0. Either way the streams get a chance to + # release a last line that never got its terminator. Drain the shims we + # made rather than sys.stdout, which the script is free to replace, and + # report instead of raising so cleanup cannot bury the real exit code. + try: + runpy.run_path(script_path, run_name="__main__") + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_shim.drain() + finally: + stderr_shim.drain() return 0 diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index c49220a044..9bb2205a90 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -179,12 +179,24 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES - sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) - sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + stdout_redirect = sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + stderr_redirect = sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) import platformio.__main__ - return platformio.__main__.main() or 0 + # PlatformIO exits through ``sys.exit``, so drain from a finally to give + # a last line without a terminator a chance to reach the user. Drain the + # wrappers we made rather than sys.stdout, which PlatformIO is free to + # replace while it runs. + try: + return platformio.__main__.main() or 0 + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() if __name__ == "__main__": diff --git a/esphome/util.py b/esphome/util.py index 5bb341b700..71c8334a02 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -174,6 +174,51 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) + def _emit_line(self, line: str) -> None: + line_without_ansi = ANSI_ESCAPE.sub("", line) + line_without_end = line_without_ansi.rstrip() + if ( + self._filter_pattern is not None + and self._filter_pattern.match(line_without_end) is not None + ): + # Filter pattern matched, ignore the line + return + + self._write_color_replace(line) + # Check for flash size error and provide helpful guidance + if ( + "Error: The program size" in line + and "is greater than maximum allowed" in line + and (help_msg := get_esp32_arduino_flash_error_help()) + ): + self._write_color_replace(help_msg) + for callback in self._line_callbacks: + if msg := callback(line_without_end): + self._write_color_replace(msg) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + A tool that dies part way through a line, or ends its output without + a final newline, would otherwise have that text sit in the buffer + and never reach the user. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit_line(line + "\n") + self._out.flush() + except (OSError, ValueError) as err: + # Every caller drains from a cleanup path, where the command's + # real result is already on its way out; raising here would + # replace it with an unrelated traceback. Carry the line into + # the warning, since the stream we were told to write it to is + # the one that just failed. + _LOGGER.warning("Could not write out remaining output (%s): %s", err, line) + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a @@ -192,27 +237,7 @@ class RedirectText: self._line_buffer = line break self._line_buffer = "" - - line_without_ansi = ANSI_ESCAPE.sub("", line) - line_without_end = line_without_ansi.rstrip() - if ( - self._filter_pattern is not None - and self._filter_pattern.match(line_without_end) is not None - ): - # Filter pattern matched, ignore the line - continue - - self._write_color_replace(line) - # Check for flash size error and provide helpful guidance - if ( - "Error: The program size" in line - and "is greater than maximum allowed" in line - and (help_msg := get_esp32_arduino_flash_error_help()) - ): - self._write_color_replace(help_msg) - for callback in self._line_callbacks: - if msg := callback(line_without_end): - self._write_color_replace(msg) + self._emit_line(line) else: self._write_color_replace(s) @@ -261,11 +286,11 @@ def run_external_command( _LOGGER.debug("Running: %s", full_cmd) orig_stdout = sys.stdout - sys.stdout = RedirectText( + stdout_redirect = sys.stdout = RedirectText( sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks ) orig_stderr = sys.stderr - sys.stderr = RedirectText( + stderr_redirect = sys.stderr = RedirectText( sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks ) @@ -291,6 +316,18 @@ def run_external_command( sys.stdout = orig_stdout sys.stderr = orig_stderr + # Release a last line that never got its terminator. This runs after + # the real streams are back, and uses the wrappers we made rather + # than whatever the command left in sys.stdout, so it cannot strand + # them. With capture_stdout the stdout wrapper was never written to, + # so draining it does nothing. Drain stderr from a finally so a + # surprise from the first one cannot strand the second; a real bug + # still propagates, it just does not take the other line with it. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() + if capture_stdout: return cap_stdout.getvalue() diff --git a/tests/unit_tests/fixtures/espidf/closing_probe.py b/tests/unit_tests/fixtures/espidf/closing_probe.py new file mode 100644 index 0000000000..a77d5c8f28 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/closing_probe.py @@ -0,0 +1,11 @@ +"""Leave a partial line behind and then close the stream under the runner. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining +cannot work here; the point is that the failure is reported rather than +raised out of the runner's cleanup, where it would bury the exit code. +""" + +import sys + +sys.stdout.write("partial before close") +sys.stdout.close() diff --git a/tests/unit_tests/fixtures/espidf/crashing_probe.py b/tests/unit_tests/fixtures/espidf/crashing_probe.py new file mode 100644 index 0000000000..bf434cc24e --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/crashing_probe.py @@ -0,0 +1,11 @@ +"""Die part way through a line, the way a build that blows up does. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +message has no trailing newline, so the runner's shim is holding it when +the process exits; nothing else will ever come to release it. +""" + +import sys + +sys.stdout.write("FATAL: ld returned 1 exit status") +sys.exit(2) diff --git a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py new file mode 100644 index 0000000000..9c81f8eb7b --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py @@ -0,0 +1,10 @@ +"""End on an unterminated line that the filter is supposed to drop. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to +check that releasing a held-back line still applies the filter. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("Project build complete.") diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index 831c8d1cc8..2c8fc7304b 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -19,10 +19,10 @@ from esphome.espidf import runner FIRST_LINE_TIMEOUT = 10.0 -def _run_main( +def _prepare_main( monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str ) -> tuple[io.BytesIO, io.TextIOWrapper]: - """Run ``runner.main()`` in-process against a buffered fake stdout. + """Point ``runner.main()`` at *probe* with a buffered fake stdout. ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and ``os.get_terminal_size``; every one of those is monkeypatched so it is @@ -39,6 +39,14 @@ def _run_main( monkeypatch.setattr(sys, "stderr", stream) monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) + return buf, stream + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Run ``runner.main()`` against *probe* and expect a clean exit.""" + buf, stream = _prepare_main(monkeypatch, probe, *args) assert runner.main() == 0 return buf, stream @@ -59,8 +67,73 @@ def test_main_filters_noise_and_flushes_each_write( # Matched by FILTER_IDF_LINES, so they never leave the runner. assert "Project build complete." not in output assert "-- Component paths:" not in output - # Held back because no terminator arrived. - assert "still going" not in output + # Held back until the end because no terminator arrived. + assert output.endswith("still going\n") + + +def test_main_drains_a_partial_line_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A build that stops mid line must still show that line. + + This is the whole point of draining: the message explaining why the + build failed is exactly the one most likely to arrive without a + trailing newline. + """ + buf, _stream = _prepare_main( + monkeypatch, fixture_path / "espidf" / "crashing_probe.py" + ) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 2 + assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n" + + +def test_main_reports_rather_than_raises_when_draining_fails( + monkeypatch: pytest.MonkeyPatch, + fixture_path: Path, + capfd: pytest.CaptureFixture[str], +) -> None: + """A stream that closed under us must not crash the runner's cleanup. + + The drain runs from a ``finally``, so an exception there would replace + whatever exit code the build was carrying back. + """ + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + reported = capfd.readouterr().err + assert "Could not write out remaining output" in reported + # The held line has to come along; the stream it was meant for is gone. + assert "partial before close" in reported + + +def test_main_survives_a_drain_failure_with_nowhere_to_report_it( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """With no real stderr to report to, cleanup still must not raise. + + ``sys.__stderr__`` is None on some interpreters, and ``print(file=None)`` + falls back to ``sys.stdout``, which here is the shim wrapping the stream + that just failed. + """ + monkeypatch.setattr(sys, "__stderr__", None) + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py" + ) + + assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n" def test_main_keeps_everything_in_verbose_mode( diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py new file mode 100644 index 0000000000..f375aa457a --- /dev/null +++ b/tests/unit_tests/test_platformio_runner.py @@ -0,0 +1,93 @@ +"""Tests for esphome.platformio.runner.""" + +from __future__ import annotations + +from collections.abc import Callable +import io +import sys +from types import ModuleType + +import pytest + +from esphome.platformio import runner + + +def _prepare_main( + monkeypatch: pytest.MonkeyPatch, pio_main: Callable[[], int] +) -> io.BytesIO: + """Point ``runner.main()`` at a fake PlatformIO with a fake stdout. + + The real ``main`` patches PlatformIO internals and then hands control to + it; both are stubbed out so only the stream wrapping is exercised. The + fake stdout is block buffered like a pipe, so the caller can see what + actually left the wrapper. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "argv", ["pio", "run"]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(runner, "patch_structhash", lambda: None) + monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + + platformio = ModuleType("platformio") + platformio_main = ModuleType("platformio.__main__") + platformio_main.main = pio_main # type: ignore[attr-defined] + platformio.__main__ = platformio_main # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "platformio", platformio) + monkeypatch.setitem(sys.modules, "platformio.__main__", platformio_main) + + return buf + + +def test_main_drains_a_partial_line_on_a_clean_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A build ending mid line still shows that line.""" + + def pio_main() -> int: + print("Linking .pioenvs/firmware.elf\n", end="") + print("Building took 12.4 seconds", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue().decode("utf-8") == ( + "Linking .pioenvs/firmware.elf\nBuilding took 12.4 seconds\n" + ) + + +def test_main_drains_when_platformio_exits_early( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leaving through ``sys.exit`` still drains, because it runs in a finally.""" + + def pio_main() -> int: + print("*** [.pioenvs/firmware.elf] Error 1", end="") + sys.exit(1) + + buf = _prepare_main(monkeypatch, pio_main) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 1 + assert buf.getvalue().decode("utf-8") == "*** [.pioenvs/firmware.elf] Error 1\n" + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + + def pio_main() -> int: + # Matches FILTER_PLATFORMIO_LINES, and arrives without a terminator. + print("Verbose mode can be enabled via `-v, --verbose` option", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue() == b"" diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index bd3d3d4836..006464842c 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +import logging from pathlib import Path import subprocess import sys @@ -442,6 +443,69 @@ def test_redirect_text_flushes_so_piped_output_streams() -> None: assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" +def test_redirect_text_drain_releases_held_partial_line() -> None: + """A last line with no terminator must still reach the user. + + A tool that dies part way through a line leaves that text in the buffer, + and it is usually the message saying what went wrong. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + # Still held: no terminator has arrived. + assert buf.getvalue() == "" + + redirect.drain() + + assert buf.getvalue() == "FATAL: ld returned 1 exit status\n" + + +def test_redirect_text_drain_still_applies_the_filter() -> None: + """Releasing a held line does not smuggle noise past the filter.""" + redirect, buf = _make_redirect(filter_lines=["Verbose mode can be enabled"]) + redirect.write("Verbose mode can be enabled") + + redirect.drain() + + assert buf.getvalue() == "" + + +def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None: + """Draining twice, or with an empty buffer, writes nothing extra.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("complete line\n") + + redirect.drain() + redirect.drain() + + assert buf.getvalue() == "complete line\n" + + +def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None: + """An out-of-flash error gets the how-to-fix note appended.""" + monkeypatch.setattr( + util, "get_esp32_arduino_flash_error_help", lambda: "TIP: switch to esp-idf\n" + ) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert "Error: The program size" in buf.getvalue() + assert "TIP: switch to esp-idf" in buf.getvalue() + + +def test_redirect_text_skips_flash_size_help_on_other_platforms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The note is ESP32-with-Arduino only, so elsewhere the line stands alone.""" + monkeypatch.setattr(util, "get_esp32_arduino_flash_error_help", lambda: None) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + def test_redirect_text_callback_called_on_matching_line() -> None: """Test that a line callback is called and its output is written.""" results: list[str] = [] @@ -571,6 +635,140 @@ def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> N assert "CALLBACK FIRED" in captured.out +def test_run_external_command_drains_partial_line( + capsys: pytest.CaptureFixture, +) -> None: + """A command that stops mid line still shows that line. + + esptool runs in-process here, so a message it writes without a trailing + newline would otherwise be dropped when the streams are put back. + """ + + def fake_main() -> int: + print("A fatal error occurred: no serial data", end="") + return 1 + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 1 + assert "A fatal error occurred: no serial data" in capsys.readouterr().out + + +def test_run_external_command_drains_on_early_exit( + capsys: pytest.CaptureFixture, +) -> None: + """The drain also happens when the command exits through ``sys.exit``.""" + + def fake_main() -> int: + print("Fatal: bailing out", end="") + sys.exit(3) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 3 + assert "Fatal: bailing out" in capsys.readouterr().out + + +def test_run_external_command_capture_stdout_has_nothing_to_drain() -> None: + """With ``capture_stdout`` there is nothing held to write out. + + The stdout wrapper still gets built, but ``sys.stdout`` is replaced by + the capture buffer right after, so the wrapper never sees a write and + draining it does nothing. + """ + + def fake_main() -> int: + print("captured output", end="") + return 0 + + out = util.run_external_command( + fake_main, "fake", capture_stdout=True, filter_lines=["ignore me"] + ) + + assert out == "captured output" + + +def test_run_external_command_survives_a_command_that_swaps_stdout( + capsys: pytest.CaptureFixture, +) -> None: + """Draining must not depend on what the command left in ``sys.stdout``. + + A command is free to replace the stream; reaching for ``drain`` on + whatever it left there would raise from the cleanup path and bury the + real exit code. + """ + + def fake_main() -> int: + print("before the swap", end="") + sys.stdout = io.StringIO() + sys.exit(7) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 7 + assert "before the swap" in capsys.readouterr().out + + +def test_drain_reports_the_lost_line_instead_of_raising( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken stream during cleanup is reported, not raised. + + The warning carries the held text, because the stream we were asked to + write it to is the one that just failed. + """ + caplog.set_level(logging.WARNING, logger=util.__name__) + out = MagicMock() + out.write.side_effect = BrokenPipeError("pipe is gone") + redirect = util.RedirectText(out, filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + redirect.drain() + + assert "pipe is gone" in caplog.text + assert "FATAL: ld returned 1 exit status" in caplog.text + + +def test_drain_lets_other_errors_through() -> None: + """Only an unusable stream is tolerated; a bug still has to be visible.""" + + def broken_callback(line: str) -> str | None: + raise TypeError("a line callback is broken") + + redirect, _buf = _make_redirect(line_callbacks=[broken_callback]) + redirect.write("a line with no terminator") + + with pytest.raises(TypeError): + redirect.drain() + + +def test_run_external_command_drains_stderr_even_if_stdout_drain_raises( + capsys: pytest.CaptureFixture, +) -> None: + """One stream failing must not strand the other's held line. + + ``drain`` deliberately lets anything that is not a stream error through, + so a broken line callback would otherwise skip the stderr drain and take + that line down with it. + """ + + def broken_on_stdout(line: str) -> str | None: + if "stdout" in line: + raise TypeError("a line callback is broken") + return None + + def fake_main() -> int: + print("stdout partial", end="") + print("stderr FATAL: the real reason", end="", file=sys.stderr) + return 0 + + with pytest.raises(TypeError): + util.run_external_command(fake_main, "fake", line_callbacks=[broken_on_stdout]) + + # The bug still surfaces, but stderr's held line was written first. + assert "stderr FATAL: the real reason" in capsys.readouterr().err + + def test_run_external_process_line_callbacks() -> None: """Test that run_external_process passes line_callbacks to RedirectText.""" results: list[str] = [] From 55e8bc3b1478aa19ad710774603230bf51c04196 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:23:36 +0000 Subject: [PATCH 097/597] Bump aioesphomeapi from 45.8.0 to 45.9.0 (#18283) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a90d9ec9eb..4e7de9eff1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.8.0 +aioesphomeapi==45.9.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 3ae651af7be5939b084f5bf1de985a560910d973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 12:38:55 -0500 Subject: [PATCH 098/597] [core] Stop dropping complete lines behind an unfinished one (#18279) --- esphome/espidf/runner.py | 31 ++++++++++------ esphome/util.py | 21 +++++++---- .../fixtures/espidf/formfeed_probe.py | 12 +++++++ tests/unit_tests/test_espidf_runner.py | 11 ++++++ tests/unit_tests/test_util.py | 36 +++++++++++++++++++ 5 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 tests/unit_tests/fixtures/espidf/formfeed_probe.py diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 298a21fb82..7ed11d7554 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -144,12 +144,14 @@ def main() -> int: * ``isatty()`` unconditionally returns True, tricking downstream code into emitting TTY-format output. - * Input is split on ``\\n`` / ``\\r`` via - ``str.splitlines(keepends=True)`` and any complete line whose + * Input is split with ``str.splitlines(keepends=True)``, which + breaks on more than ``\\n`` and ``\\r``; form feed and a few + other control characters count too. Any piece whose ANSI-stripped, right-stripped form matches one of ``filter_lines`` is dropped. - * Incomplete trailing chunks are held in a buffer until a - terminator arrives. + * Only the final piece can still be waiting for more text, so + that one is held until a ``\\n`` or ``\\r`` arrives. A piece + that ended on one of the other breaks goes out as it is. Mirrors the matching semantics of ``esphome.util.RedirectText`` so filter patterns behave identically in both the PlatformIO @@ -228,13 +230,22 @@ def main() -> int: # Nothing to match against, so no need to wait for a full line. self._emit(data) else: - self._line_buffer += data - for line in self._line_buffer.splitlines(keepends=True): - if "\n" not in line and "\r" not in line: - # Incomplete — hold until we see a terminator. - self._line_buffer = line - break + lines = (self._line_buffer + data).splitlines(keepends=True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one + # can still be waiting for more text. Hold that one, write + # out the rest. + # + # Some of those breaks are not line endings to us, a form + # feed for one, so a piece can go out without ending in a + # newline. That beats what we did before, which was to stop + # at the first such piece and drop every complete line + # behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: self._line_buffer = "" + for line in lines: self._emit(line) # We tell idf.py it is talking to a terminal, so it sends progress diff --git a/esphome/util.py b/esphome/util.py index 71c8334a02..0b9025c73b 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -229,14 +229,21 @@ class RedirectText: s = s.decode() if self._filter_pattern is not None or self._line_callbacks: - self._line_buffer += s - lines = self._line_buffer.splitlines(True) - for line in lines: - if "\n" not in line and "\r" not in line: - # Not a complete line, set line buffer - self._line_buffer = line - break + lines = (self._line_buffer + s).splitlines(True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one can + # still be waiting for more text. Hold that one, write out the + # rest. + # + # Some of those breaks are not line endings to us, a form feed + # for one, so a piece can go out without ending in a newline. + # That beats what we did before, which was to stop at the first + # such piece and drop every complete line behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: self._line_buffer = "" + for line in lines: self._emit_line(line) else: self._write_color_replace(s) diff --git a/tests/unit_tests/fixtures/espidf/formfeed_probe.py b/tests/unit_tests/fixtures/espidf/formfeed_probe.py new file mode 100644 index 0000000000..727cda25ce --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/formfeed_probe.py @@ -0,0 +1,12 @@ +"""Write a form feed part way through the output. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form +feed is not a line terminator here, so everything written must still come +out, including the complete lines that follow it. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("page one\x0cpage two\n") +sys.stdout.write("[2/9] Building C object\n") diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index 2c8fc7304b..e4cc6e137e 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -71,6 +71,17 @@ def test_main_filters_noise_and_flushes_each_write( assert output.endswith("still going\n") +def test_main_keeps_output_after_a_form_feed( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A form feed is text, not a line break, so nothing after it is lost.""" + buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py") + + assert buf.getvalue().decode("utf-8") == ( + "Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n" + ) + + def test_main_drains_a_partial_line_when_the_build_dies( monkeypatch: pytest.MonkeyPatch, fixture_path: Path ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 006464842c..fcd8bf2e9c 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -443,6 +443,42 @@ def test_redirect_text_flushes_so_piped_output_streams() -> None: assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" +@pytest.mark.parametrize( + "break_char", + ["\x0c", "\x0b", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"], + ids=["formfeed", "vtab", "fs", "gs", "rs", "nel", "lsep", "psep"], +) +def test_redirect_text_keeps_output_after_an_exotic_break_character( + break_char: str, +) -> None: + r"""Only ``\n`` and ``\r`` end a line; the rest is ordinary text. + + ``str.splitlines`` treats all of these as line breaks. Splitting on them + used to strand the fragment in the buffer and drop every complete line + that came after it, which for a form feed in toolchain output meant + losing the rest of the build log. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write(f"first{break_char}second\nthird\n") + + assert buf.getvalue() == f"first{break_char}second\nthird\n" + + +def test_redirect_text_treats_crlf_as_one_terminator() -> None: + r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("one\r\ntwo\rthree\nfour") + + # "four" has no terminator yet, so it is held back. + assert buf.getvalue() == "one\r\ntwo\rthree\n" + + redirect.drain() + + assert buf.getvalue() == "one\r\ntwo\rthree\nfour\n" + + def test_redirect_text_drain_releases_held_partial_line() -> None: """A last line with no terminator must still reach the user. From 55bd63732d984ab8b8227015594201d565ed82ff Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 11 Aug 2026 20:56:12 +0200 Subject: [PATCH 099/597] [mitsubishi_cn105] Add vertical vane state trigger (#16727) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 19 +++++++- .../mitsubishi_cn105_component.cpp | 2 +- .../mitsubishi_cn105_component.h | 34 ++++++++++++- tests/components/mitsubishi_cn105/common.h | 7 +++ tests/components/mitsubishi_cn105/common.yaml | 5 ++ .../mitsubishi_cn105_component_tests.cpp | 48 +++++++++++++++++++ ...bishi_cn105_vane_select_vertical_tests.cpp | 7 --- 7 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 7d5594495a..70ed0a7a85 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL +from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL from esphome.core import ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType @@ -13,6 +13,7 @@ DOMAIN = "mitsubishi_cn105" CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" +CONF_VANE = "vane" mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) @@ -22,6 +23,8 @@ MitsubishiCN105Component = mitsubishi_ns.class_( uart.UARTDevice, ) +VaneState = mitsubishi_ns.struct("VaneState") + SetRemoteTemperatureAction = mitsubishi_ns.class_( "SetRemoteTemperatureAction", automation.Action, @@ -42,6 +45,11 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional(CONF_VANE): cv.Schema( + { + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -80,6 +88,15 @@ async def to_code(config: ConfigType) -> None: config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] ) ) + if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): + cg.add_global(mitsubishi_ns.using) + for conf in on_state: + await automation.build_callback_automation( + var, + "add_on_vane_state_callback", + [(VaneState.operator("const").operator("ref"), "x")], + conf, + ) REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 166e7fbf88..5314965af6 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -27,7 +27,7 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); } void MitsubishiCN105Component::loop() { if (this->hp_.update()) { - this->status_callback_.call(); + this->notify_status_listeners_(); } } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 1caf779f40..64077432fd 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -9,6 +9,24 @@ namespace esphome::mitsubishi_cn105 { +enum VerticalVaneMode : uint8_t { + VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), + VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), + VERTICAL_VANE_MODE_POSITION_2 = static_cast(MitsubishiCN105::VaneMode::POSITION_2), + VERTICAL_VANE_MODE_POSITION_3 = static_cast(MitsubishiCN105::VaneMode::POSITION_3), + VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), + VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), + VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), +}; + +struct VaneState { + struct Vertical { + VerticalVaneMode direction; + }; + + Vertical vertical; +}; + class MitsubishiCN105Component : public Component, public uart::UARTDevice { public: explicit MitsubishiCN105Component() : hp_(*this) {} @@ -38,15 +56,29 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { this->status_callback_.add(std::forward(callback)); } + template void add_on_vane_state_callback(F &&callback) { + this->vane_state_callback_.add(std::forward(callback)); + } + void publish_status() { if (this->is_status_initialized()) { - this->status_callback_.call(); + this->notify_status_listeners_(); } } protected: + void notify_status_listeners_() { + this->status_callback_.call(); + if (this->status().vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); + } + } + MitsubishiCN105 hp_; CallbackManager status_callback_; + LazyCallbackManager vane_state_callback_; }; } // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index b90ddf3995..a119a38d24 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -77,4 +77,11 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { MitsubishiCN105Component component_; }; +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } +}; + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 12a3b8ce9d..fcd1b048dd 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,11 @@ mitsubishi_cn105: uart_id: uart_bus update_interval: 30s telemetry_request_min_interval: 120s + vane: + on_state: + - logger.log: + format: "TRIGGER: vane on_state is auto: %s" + args: ['x.vertical.direction == VERTICAL_VANE_MODE_AUTO ? "yes" : "no"'] climate: - platform: mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp new file mode 100644 index 0000000000..48ea6b0c29 --- /dev/null +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -0,0 +1,48 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) { + TestableMitsubishiCN105Component hub; + size_t callback_count = 0; + std::optional callback_direction; + hub.add_on_vane_state_callback([&](const VaneState &state) { + callback_count++; + callback_direction = state.vertical.direction; + }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); + + hub.publish_status(); + + EXPECT_EQ(callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, DoesNotPublishUnknownVaneState) { + TestableMitsubishiCN105Component hub; + size_t status_callback_count = 0; + size_t vane_callback_count = 0; + hub.add_on_status_callback([&]() { status_callback_count++; }); + hub.add_on_vane_state_callback([&](const VaneState &) { vane_callback_count++; }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 1); + EXPECT_EQ(vane_callback_count, 0); + + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 2); + EXPECT_EQ(vane_callback_count, 1); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp index 4c980d69d8..1f928e3bf4 100644 --- a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -3,13 +3,6 @@ namespace esphome::mitsubishi_cn105::testing { -class TestableMitsubishiCN105Component : public MitsubishiCN105Component { - public: - MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } - - void notify_status() { this->status_callback_.call(); } -}; - class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { public: using MitsubishiCN105VerticalVaneDirectionSelect::control; From 94fbfa05de1103c7ce4f4d1a437f0687b6a2c7d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 14:35:06 -0500 Subject: [PATCH 100/597] [core] Show the out-of-flash tip instead of crashing the build (#18280) --- esphome/core/__init__.py | 5 ++ esphome/platformio/toolchain.py | 9 ++- esphome/util.py | 24 +++++- tests/unit_tests/test_platformio_toolchain.py | 63 ++++++++++++++- tests/unit_tests/test_util.py | 79 ++++++++++++++++++- 5 files changed, 172 insertions(+), 8 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1a5f4f2cf5..534b740a5d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -885,6 +885,11 @@ class EsphomeCore: return self.relative_build_path("build", "bootloader", "bootloader.bin") return self.relative_pioenvs_path(self.name, "bootloader.bin") + @property + def is_configured(self) -> bool: + """Whether anything has set this CORE up for a target.""" + return KEY_CORE in self.data + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 32e30290ac..0e7ffce939 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -19,7 +19,7 @@ from esphome.helpers import ( rmtree, write_file, ) -from esphome.util import FlashImage, run_external_process +from esphome.util import ESP32_ARDUINO_ENV, FlashImage, run_external_process if TYPE_CHECKING: from platformio.project.config import ProjectConfig @@ -342,6 +342,13 @@ def run_platformio_cli(*args, **kwargs) -> str | int: base_env = kwargs.pop("env", None) env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) + # The runner offers the out-of-flash tip but has no configured CORE, so + # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same + # variable; clear an inherited one so it cannot reach the wrong build. + if CORE.is_configured and CORE.is_esp32 and CORE.using_arduino: + env[ESP32_ARDUINO_ENV] = "1" + else: + env.pop(ESP32_ARDUINO_ENV, None) return run_external_process(*cmd, env=env, **kwargs) diff --git a/esphome/util.py b/esphome/util.py index 0b9025c73b..2fc34f3a69 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass import io import logging +import os from pathlib import Path import re import sys @@ -141,6 +142,10 @@ def shlex_quote(s: str | Path) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" +# Tells the PlatformIO runner subprocess, which has no configured CORE, that +# this is an ESP32 Arduino build. +ESP32_ARDUINO_ENV = "ESPHOME_ESP32_ARDUINO_BUILD" + ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") @@ -520,11 +525,24 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: return BootselResult(0) -def get_esp32_arduino_flash_error_help() -> str | None: - """Returns helpful message when ESP32 with Arduino runs out of flash space.""" +def is_esp32_arduino_build() -> bool: + """Whether the build targets ESP32 with the Arduino framework. + + The PlatformIO runner subprocess has no configured CORE, so the parent + passes the answer in the environment. + """ from esphome.core import CORE - if not (CORE.is_esp32 and CORE.using_arduino): + if not CORE.is_configured: + # The runner subprocess. A half filled in CORE still counts as + # configured, so reading from it raises instead of landing here. + return os.environ.get(ESP32_ARDUINO_ENV) == "1" + return CORE.is_esp32 and CORE.using_arduino + + +def get_esp32_arduino_flash_error_help() -> str | None: + """Returns helpful message when ESP32 with Arduino runs out of flash space.""" + if not is_esp32_arduino_build(): return None from esphome.log import AnsiFore, color diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 9450e8e0e1..02c11b4e45 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM from esphome.core import CORE, EsphomeError from esphome.platformio import runner, toolchain -from esphome.util import FlashImage +from esphome.util import ESP32_ARDUINO_ENV, FlashImage def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -328,6 +329,66 @@ def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: _ = toolchain.IDEData({"extra": None}).extra_flash_images +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", "1"), + ("esp32", "esp-idf", None), + ("esp8266", "arduino", None), + ], +) +def test_run_platformio_cli_flags_an_esp32_arduino_build( + setup_core: Path, + mock_run_external_process: Mock, + platform: str, + framework: str, + expected: str | None, +) -> None: + """Only an ESP32 Arduino build is flagged, and an inherited one is cleared.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get(ESP32_ARDUINO_ENV) == expected + # Only the subprocess env is touched; ours is left as it was. + assert os.environ[ESP32_ARDUINO_ENV] == "1" + + +def test_run_platformio_cli_ignores_an_inherited_flag_without_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """An inherited flag must not end up answering for CORE.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data.pop(KEY_CORE, None) + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert ESP32_ARDUINO_ENV not in env + + +def test_run_platformio_cli_raises_on_a_half_filled_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """A CORE set up but left incomplete must surface, not fall back.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = {} + + with patch.dict(os.environ, {}, clear=False): + mock_run_external_process.return_value = 0 + with pytest.raises(KeyError): + toolchain.run_platformio_cli("test", "arg") + + def test_run_platformio_cli_sets_environment_variables( setup_core: Path, mock_run_external_process: Mock ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index fcd8bf2e9c..a4b091b7c2 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -14,6 +14,8 @@ from unittest.mock import MagicMock, patch import pytest from esphome import util +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None: @@ -517,6 +519,80 @@ def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None: assert buf.getvalue() == "complete line\n" +def test_flash_error_help_is_quiet_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: reading the platform used to raise in the runner.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.get_esp32_arduino_flash_error_help() is None + + +def test_flash_error_help_reads_the_env_var_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The parent tells the subprocess what it cannot work out for itself.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + help_msg = util.get_esp32_arduino_flash_error_help() + + assert help_msg is not None + assert "esp-idf" in help_msg + + +def test_is_esp32_arduino_build_raises_on_a_half_filled_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A half filled in CORE is a bug, so it must raise, not fall back.""" + + monkeypatch.setattr(CORE, "data", {KEY_CORE: {}}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + with pytest.raises(KeyError): + util.is_esp32_arduino_build() + + +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", True), + ("esp32", "esp-idf", False), + ("esp8266", "arduino", False), + ], +) +def test_is_esp32_arduino_build_from_a_configured_core( + monkeypatch: pytest.MonkeyPatch, platform: str, framework: str, expected: bool +) -> None: + """With CORE set up, it is the source of truth and the env var is ignored.""" + + monkeypatch.setattr( + CORE, + "data", + {KEY_CORE: {KEY_TARGET_PLATFORM: platform, KEY_TARGET_FRAMEWORK: framework}}, + ) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.is_esp32_arduino_build() is expected + + +def test_redirect_text_survives_a_flash_error_without_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The overflow line goes through even from a process with no CORE.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None: """An out-of-flash error gets the how-to-fix note appended.""" monkeypatch.setattr( @@ -971,7 +1047,6 @@ class TestSafePrint: @pytest.fixture(autouse=True) def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None: """Default ``CORE.dashboard`` to False so each test starts hermetic.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", False) @@ -993,7 +1068,6 @@ class TestSafePrint: monkeypatch: pytest.MonkeyPatch, ) -> None: r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) util.safe_print("\033[0;32mhi\033[0m") @@ -1060,7 +1134,6 @@ class TestSafePrint: self, monkeypatch: pytest.MonkeyPatch ) -> None: """Dashboard ESC escaping + cp1252 fallback compose correctly.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) buf = io.BytesIO() From 74c30c62ef97c3545b3bf313742a86c2522db489 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:36:59 -0500 Subject: [PATCH 101/597] Bump setuptools from 83.0.0 to 84.0.0 (#18290) Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eda3c4cf7c..166b3cf6bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] From b9d1d2f06b34a55ce9bec11d660771e86d93e117 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:02:53 +0000 Subject: [PATCH 102/597] Bump aioesphomeapi from 45.9.0 to 45.10.0 (#18294) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4e7de9eff1..9c231bd0fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.9.0 +aioesphomeapi==45.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 4a7de87bffde0729fccec91735480015c54a31e2 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 16:33:37 -0400 Subject: [PATCH 103/597] [audio] Bump microDecoder to v0.4.0 (#18291) --- esphome/components/audio/__init__.py | 4 +++- esphome/idf_component.yml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d87f32fc36..1c522cbb5d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -371,7 +371,7 @@ async def to_code(config): data.wav_support = True if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.2.0") + add_idf_component(name="esphome/micro-decoder", ref="0.4.0") # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash if not data.flac_support: @@ -380,6 +380,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) if not data.opus_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) + # Vorbis is unsupported in ESPHome, so always disable it + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False) if not data.wav_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9448b93cc9..6a9d7171ec 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -8,7 +8,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.2.0 + version: 0.4.0 esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: From 7f0d6a86968ab05d548e878671697d8133d676b9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 16:49:08 -0400 Subject: [PATCH 104/597] [sendspin] Add image platform for artwork (#17937) --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 61 +++- esphome/components/sendspin/image/__init__.py | 228 +++++++++++++++ .../components/sendspin/image/automation.h | 20 ++ .../sendspin/image/sendspin_image.cpp | 261 ++++++++++++++++++ .../sendspin/image/sendspin_image.h | 185 +++++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 47 ++++ esphome/components/sendspin/sendspin_hub.h | 44 +++ tests/component_tests/sendspin/__init__.py | 0 tests/component_tests/sendspin/test_image.py | 114 ++++++++ tests/components/sendspin/common-image.yaml | 47 ++++ .../sendspin/test-image-lvgl.esp32-idf.yaml | 66 +++++ .../sendspin/test-image.esp32-idf.yaml | 3 + 13 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/image/__init__.py create mode 100644 esphome/components/sendspin/image/automation.h create mode 100644 esphome/components/sendspin/image/sendspin_image.cpp create mode 100644 esphome/components/sendspin/image/sendspin_image.h create mode 100644 tests/component_tests/sendspin/__init__.py create mode 100644 tests/component_tests/sendspin/test_image.py create mode 100644 tests/components/sendspin/common-image.yaml create mode 100644 tests/components/sendspin/test-image-lvgl.esp32-idf.yaml create mode 100644 tests/components/sendspin/test-image.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 253b0c05b1..9ddbca5c71 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -466,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index d0c2112ba9..bd889c2c92 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg @@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_FORMAT, + CONF_HEIGHT, CONF_ID, CONF_SAMPLE_RATE, + CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_WIDTH, ) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType @@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. +MAX_ARTWORK_SLOTS = 4 + # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -36,9 +44,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) +IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") +IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") +IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") + +SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) +IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") +IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST") + # Library Structs AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig") +ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference") # MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement # preference for the player role's transfer buffers. @@ -76,6 +95,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -110,6 +130,22 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_artwork_preference(config: ConfigType) -> int: + """Register an artwork slot preference and return the slot it was given. + + A slot is a preference's position in the list, which is also the order the roles are + advertised to the server in. + """ + request_artwork_support() + preferences = _get_data().artwork_preferences + if len(preferences) >= MAX_ARTWORK_SLOTS: + raise cv.Invalid( + f"Too many Sendspin image slots. Maximum is {MAX_ARTWORK_SLOTS}." + ) + preferences.append(config) + return len(preferences) - 1 + + def register_player_config(config: ConfigType) -> None: """Register the player role config from the media source subcomponent.""" data = _get_data() @@ -211,6 +247,29 @@ async def to_code(config: ConfigType) -> None: # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). if data.artwork_support: cg.add_define("USE_SENDSPIN_ARTWORK", True) + + # require_frame_done is always on: SendspinImageSlot always acks a delivery, either + # immediately or from the transition_finished action. + preference_structs = [ + cg.StructInitializer( + ImageSlotPreference, + ("source", pref[CONF_SOURCE]), + ("format", pref[CONF_FORMAT]), + ("width", pref[CONF_WIDTH]), + ("height", pref[CONF_HEIGHT]), + ("require_frame_done", True), + ("display_offset_ms", pref[CONF_DISPLAY_OFFSET]), + ) + for pref in data.artwork_preferences + ] + + artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM)) + artwork_config = cg.StructInitializer( + ArtworkRoleConfig, + ("preferred_formats", preference_structs), + ("psram_stack", artwork_psram_stack), + ) + cg.add(var.set_artwork_config(artwork_config)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py new file mode 100644 index 0000000000..94d6e7cfca --- /dev/null +++ b/esphome/components/sendspin/image/__init__.py @@ -0,0 +1,228 @@ +"""Sendspin image platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_HEIGHT, + CONF_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_WIDTH, +) +from esphome.core import ID +from esphome.cpp_generator import TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_DISPLAY_OFFSET, + CONF_SENDSPIN_ID, + IMAGE_FORMAT_BMP, + IMAGE_FORMAT_JPEG, + IMAGE_FORMAT_PNG, + IMAGE_SOURCE_ALBUM, + IMAGE_SOURCE_ARTIST, + SendspinHub, + register_artwork_preference, + sendspin_ns, +) + +AUTO_LOAD = ["runtime_image"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +# runtime_image refuses to size a buffer beyond this, so anything larger fails at setup rather +# than at validation. The library's ImageSlotPreference width/height fields are uint16_t, which +# is the looser of the two bounds. +MAX_IMAGE_DIMENSION = 32767 + +# Sanity bound for display_offset; the library field is int32_t milliseconds and offsets beyond +# a few seconds around the track boundary are meaningless. +MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) +MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) + +CONF_SLOT = "slot" +CONF_CURRENT_IMAGE = "current_image" +CONF_TRANSITION_IMAGE = "transition_image" +CONF_ON_IMAGE_DISPLAY = "on_image_display" +CONF_ON_IMAGE_CLEAR = "on_image_clear" +CONF_ON_IMAGE_ERROR = "on_image_error" + +# Map runtime_image's validated format string to the sendspin library's SendspinImageFormat enum. +# runtime_image accepts "JPG" as an alias for JPEG, so both keys map to the JPEG enum. +_FORMAT_TO_SENDSPIN_ENUM = { + "JPEG": IMAGE_FORMAT_JPEG, + "JPG": IMAGE_FORMAT_JPEG, + "PNG": IMAGE_FORMAT_PNG, + "BMP": IMAGE_FORMAT_BMP, +} + +# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it +# would never receive artwork while still paying for two frame buffers, so it is not offered here. +IMAGE_SOURCES = { + "ALBUM": IMAGE_SOURCE_ALBUM, + "ARTIST": IMAGE_SOURCE_ARTIST, +} + +# The platform entry configures an artwork slot; the images it shows are declared inside it. The +# slot itself is the automation target (triggers and the transition_finished action). +SendspinImageSlot = sendspin_ns.class_( + "SendspinImageSlot", + cg.Component, + cg.Parented.template(SendspinHub), +) +ArtworkImageView = sendspin_ns.class_("ArtworkImageView", Image_) + +# A dict rather than a bare ID so per-image options can be added later without a new top-level key. +_IMAGE_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.declare_id(ArtworkImageView)}) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_IMAGE_DISPLAY, + "add_on_image_display_callback", + [(cg.uint32, "lateness_ms")], + ), + automation.CallbackAutomation(CONF_ON_IMAGE_CLEAR, "add_on_image_clear_callback"), + automation.CallbackAutomation(CONF_ON_IMAGE_ERROR, "add_on_image_error_callback"), +) + + +def _assign_slot_and_register(config: ConfigType) -> ConfigType: + """Register the artwork preference with the hub and record the slot it was given.""" + width, height = config[CONF_RESIZE] + if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION: + raise cv.Invalid( + f"'{CONF_RESIZE}' width and height must be {MAX_IMAGE_DIMENSION} or less", + path=[CONF_RESIZE], + ) + + config[CONF_SLOT] = register_artwork_preference( + { + CONF_SOURCE: config[CONF_SOURCE], + CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]], + CONF_WIDTH: width, + CONF_HEIGHT: height, + CONF_DISPLAY_OFFSET: config[CONF_DISPLAY_OFFSET].total_milliseconds, + } + ) + return config + + +# The format, type, resize, transparency, byte order and placeholder keys all describe the slot: +# they set what is requested from the server and how it is decoded, not either individual image. +# Only the IDs are per-image, so runtime_image_schema declares the slot itself. +CONFIG_SCHEMA = cv.All( + runtime_image.runtime_image_schema(SendspinImageSlot).extend( + { + cv.GenerateID(): cv.declare_id(SendspinImageSlot), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + # Narrow runtime_image's format list to what the library can request, so the + # accepted set and the enum map below cannot drift apart. + cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_RESIZE): cv.dimensions, + cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum( + IMAGE_SOURCES, upper=True + ), + # Positive fires on_image_display before the server's display timestamp (negative + # delays it), so a cross-fade can straddle the track boundary. + cv.Optional(CONF_DISPLAY_OFFSET, default="0ms"): cv.All( + cv.time_period, + # The library field is whole milliseconds; reject finer values rather than + # silently rounding them down to zero. + cv.time_period_in_milliseconds_, + cv.Range(min=MIN_DISPLAY_OFFSET, max=MAX_DISPLAY_OFFSET), + ), + cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_CLEAR): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation({}), + } + ), + runtime_image.validate_runtime_image_settings, + cv.only_on_esp32, + _assign_slot_and_register, +) + + +async def to_code(config: ConfigType) -> None: + settings = await runtime_image.process_runtime_image_config(config) + + def make_view(view_id: ID) -> cg.MockObj: + # Views start with no frame; the slot points them at its buffers in setup(). The size is + # given up front so the view is well formed before then. LVGL picks it up from the first + # lvgl.image.update in on_image_display, not from the widget's initial src: at that point + # the view still has no frame, so its descriptor is empty. + view = cg.new_Pvariable( + view_id, + cg.nullptr, + settings.width, + settings.height, + settings.image_type_enum, + settings.transparent, + ) + add_metadata( + view_id, + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + return view + + current_image = make_view(config[CONF_CURRENT_IMAGE][CONF_ID]) + if settings.placeholder is not None: + cg.add(current_image.set_placeholder(settings.placeholder)) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_SLOT], + current_image, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + + if (transition_image := config.get(CONF_TRANSITION_IMAGE)) is not None: + cg.add(var.set_transition_image(make_view(transition_image[CONF_ID]))) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +SendspinImageTransitionFinishedAction = sendspin_ns.class_( + "SendspinImageTransitionFinishedAction", + automation.Action, + cg.Parented.template(SendspinImageSlot), +) + + +@automation.register_action( + "sendspin.image.transition_finished", + SendspinImageTransitionFinishedAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinImageSlot), + } + ) + ), + synchronous=True, +) +async def sendspin_image_transition_finished_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h new file mode 100644 index 0000000000..154e62a4b2 --- /dev/null +++ b/esphome/components/sendspin/image/automation.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/automation.h" +#include "sendspin_image.h" + +namespace esphome::sendspin_ { + +template +class SendspinImageTransitionFinishedAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->transition_finished(); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp new file mode 100644 index 0000000000..626d7966b7 --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -0,0 +1,261 @@ +#include "sendspin_image.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.image"; + +// How long a displayed frame may wait for sendspin.image.transition_finished before a warning +// names the missing ack. Generous next to a typical fade of a second or two. +static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; + +// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists. +void SendspinImageSlot::setup() { + const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_); + if (frame_size == 0) { + // The sink would refuse a buffer of these dimensions, so every decode would fall back to + // allocating one of its own. Fail here instead, where the dimensions are already known. + ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_); + this->mark_failed(); + return; + } + + RAMAllocator allocator; + for (uint8_t *&buffer : this->buffers_) { + buffer = allocator.allocate(frame_size); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size, + allocator.get_max_free_block_size()); + for (uint8_t *&allocated : this->buffers_) { + allocator.deallocate(allocated, frame_size); + allocated = nullptr; + } + this->mark_failed(); + return; + } + // Both buffers start black, so a transition has something to fade from before any artwork + // has arrived. + memset(buffer, 0, frame_size); + } + + // Point both views at buffers_[current_index_] rather than the buffer the first decode writes + // into, so they name a frame that stays black until artwork arrives. + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + } + + this->parent_->add_image_decode_callback( + [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) { + if (slot == this->slot_) + this->on_decode_(data, length); + }); + this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) { + if (slot == this->slot_) + this->on_display_(lateness_ms); + }); + this->parent_->add_image_clear_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_clear_(); + }); +} + +// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call. +void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { + uint8_t *target; + { + // The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate + // is what guarantees the buffer it picks out is not still needed by the main loop. + LockGuard lock(this->pending_mutex_); + target = this->buffers_[this->current_index_ ^ 1]; + } + + // The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink + // is pinned to them: a decode that asks for anything else is a malformed payload and drops the + // frame. + if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) { + // setup() rules this out, but decoding without the handover would allocate a frame-sized + // buffer on this thread, which is exactly what the permanent buffers exist to avoid. + this->report_error_(); + return; + } + + const bool decoded = this->decode_frame_(data, length, target); + // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is + // safe on every path. + this->decode_sink_.release(); + + if (!decoded) { + // The buffer keeps whatever the failed decode painted into it, but no view names it while a + // decode can run, so nothing shows it. + this->report_error_(); + return; + } + + LockGuard lock(this->pending_mutex_); + this->frame_pending_ = true; +} + +// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink. +bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) { + if (!this->decode_sink_.begin_decode(length)) { + ESP_LOGE(TAG, "Could not start decode"); + return false; + } + + size_t total_consumed = 0; + while (total_consumed < length) { + int consumed = this->decode_sink_.feed_data(const_cast(data) + total_consumed, length - total_consumed); + if (consumed <= 0) { + // <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data). + ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed); + return false; + } + total_consumed += consumed; + } + + if (!this->decode_sink_.end_decode()) { + ESP_LOGE(TAG, "Could not finalize decode"); + return false; + } + + // A decode that asked for other dimensions had the buffer taken away from it, so it painted + // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // regardless, so the frame is dropped here. + return this->decode_sink_.decoded_into(target); +} + +// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached). +void SendspinImageSlot::on_display_(uint32_t lateness_ms) { + bool frame_ready; + { + LockGuard lock(this->pending_mutex_); + frame_ready = this->frame_pending_; + this->frame_pending_ = false; + if (frame_ready) { + // The decoded frame becomes the current one; the frame it replaces becomes the outgoing + // frame, and the next decode target once the transition is acked. + this->current_index_ ^= 1; + } + } + if (!frame_ready) { + // The decode for this display failed, so there is nothing new to show. The delivery still owes + // its ack or the library would withhold every later frame for this slot. + this->parent_->artwork_frame_done(this->slot_); + return; + } + + // The frame this display replaces is only real artwork if something was already on screen. + const bool outgoing_is_artwork = this->showing_artwork_; + this->showing_artwork_ = true; + this->apply_frames_(outgoing_is_artwork); + + // Armed before the trigger fires so an automation that acks synchronously still counts, and armed + // for the first frame too so the contract stays uniform: one transition_finished per display. + this->transition_pending_ = this->transition_image_ != nullptr; + if (this->transition_pending_) { + // The library holds back further deliveries until the ack, with no timeout, so an automation + // that never reaches the action stalls the slot with nothing in the log. Name the cause after + // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame + // that was already acked and superseded. + this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + if (this->transition_pending_) { + ESP_LOGW(TAG, + "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " + "sendspin.image.transition_finished runs or the stream is cleared", + this->slot_); + } + }); + } + this->image_display_callback_.call(lateness_ms); + if (this->transition_image_ == nullptr) { + this->finish_transition_(); + } +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::finish_transition_() { + this->transition_pending_ = false; + if (this->transition_image_ != nullptr) { + // Move it off the buffer the next decode writes into. What it shows does not change: the + // buffer it moves to holds the artwork the transition just settled on. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(this->showing_artwork_); + } + // The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight + // away, so nothing may still name that buffer by the time this runs. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action). +void SendspinImageSlot::transition_finished() { + if (!this->transition_pending_) { + return; + } + this->finish_transition_(); +} + +// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot). +void SendspinImageSlot::on_clear_() { + { + LockGuard lock(this->pending_mutex_); + // Drop a frame that was decoded but never displayed; its buffer stays the decode target. + this->frame_pending_ = false; + } + // No pixels are touched and the views keep naming the frames they had: a widget goes on drawing + // the last artwork until the automation points it elsewhere or hides it. Only the display lambda + // path stops drawing the artwork, falling back to the placeholder. + this->current_image_->set_showing_artwork(false); + if (this->transition_image_ != nullptr) { + // Point it away from the decode target, as at setup, so it cannot show a frame being decoded. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(false); + } + this->showing_artwork_ = false; + // Drops a running transition. Its automation cannot be cancelled here, so a late + // transition_finished() can ack the next stream's first frame early, showing it without its + // transition. The ack count stays right. + this->transition_pending_ = false; + this->image_clear_callback_.call(); + // A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame -- + // including one whose transition never signalled transition_finished(), so a stalled slot + // recovers here. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::dump_config() { + ESP_LOGCONFIG(TAG, + "Artwork slot %u:\n" + " Dimensions: %dx%d\n" + " Frame buffers: 2 x %zu bytes\n" + " Transition image: %s", + this->slot_, this->width_, this->height_, + this->decode_sink_.get_buffer_size(this->width_, this->height_), + YESNO(this->transition_image_ != nullptr)); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::apply_frames_(bool transition_is_artwork) { + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->current_image_->set_showing_artwork(true); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_); + this->transition_image_->set_showing_artwork(transition_is_artwork); + } +} + +// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe +// here because the hub enables wake_loop_threadsafe support. +void SendspinImageSlot::report_error_() { + this->defer([this]() { this->image_error_callback_.call(); }); +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h new file mode 100644 index 0000000000..2f6f4e8a4d --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -0,0 +1,185 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/components/image/image.h" +#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/core/helpers.h" + +#include + +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Decode-only RuntimeImage that decodes into a buffer owned by SendspinImageSlot. +/// +/// Runs exclusively on the sendspin library's artwork decode thread. RuntimeImage's decode path +/// overwrites the fields the display reads (data_start_/width_/height_), so it must never be the +/// object shown on screen. +class ArtworkDecodeSink : public runtime_image::RuntimeImage { + public: + using runtime_image::RuntimeImage::RuntimeImage; + + /// @brief True when the decode ended with the given buffer still in place. + /// + /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions + /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. + bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } +}; + +/// @brief A non-owning image::Image view over a buffer owned by SendspinImageSlot. +/// +/// Each slot publishes its frames through these: one for the artwork on screen, and optionally a +/// second for the outgoing frame during a cross-fade. A view always names a frame, black to begin +/// with, so LVGL can be given it as a widget source before any artwork exists. Main loop only. +class ArtworkImageView : public image::Image { + public: + using image::Image::Image; + + void set_frame(const uint8_t *data, int width, int height) { + this->data_start_ = data; + this->width_ = width; + this->height_ = height; +#ifdef USE_LVGL + // Keep the descriptor LVGL is handed in step with the frame. This does not redraw anything: + // only setting a widget's source invalidates it. + this->get_lv_image_dsc(); +#endif + } + + /// @brief Records whether the frame on show is real artwork rather than the black it starts as. + /// + /// Only changes what the display lambda path draws. The frame itself is left alone, so anything + /// reading the pixels directly (an LVGL widget) keeps drawing the last artwork until it is + /// pointed elsewhere. + void set_showing_artwork(bool showing_artwork) { this->showing_artwork_ = showing_artwork; } + + void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; } + + void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override { + if (!this->showing_artwork_) { + // Nothing worth showing yet: the placeholder if there is one, otherwise leave the area be + // rather than paint a blank frame over it. + if (this->placeholder_ != nullptr) { + this->placeholder_->draw(x, y, display, color_on, color_off); + } + return; + } + image::Image::draw(x, y, display, color_on, color_off); + } + + protected: + image::Image *placeholder_{nullptr}; + bool showing_artwork_{false}; +}; + +/// @brief A single artwork slot: owns the frame buffers and publishes them to its image views. +/// +/// BUFFERS: two buffers, allocated zeroed at setup and never freed. One holds the frame the current +/// image shows; the other holds the outgoing frame a transition shows, and is where the next +/// artwork is decoded. Each display swaps their roles. +/// +/// THREADING: the sendspin library decodes on a dedicated thread and fires display/clear on the +/// main loop. Decoding runs into decode_sink_, which writes into the buffer the current image is +/// not showing; the swap that puts it on screen happens on the main loop. Every slot enables the +/// library's require_frame_done gate, which withholds further deliveries for the slot (buffering +/// the newest payload, latest wins) until the hub's artwork_frame_done() runs. That gate is what +/// makes two buffers enough: no decode starts while the main loop still needs the outgoing frame. +/// +/// LVGL: publishing a frame to a view updates the descriptor LVGL was handed but does not +/// invalidate the widget, so every widget's source must be set again on each display. +class SendspinImageSlot : public SendspinChild { + public: + SendspinImageSlot(uint8_t slot, ArtworkImageView *current_image, int width, int height, + runtime_image::ImageFormat format, image::ImageType type, image::Transparency transparency, + bool is_big_endian) + : decode_sink_(format, type, transparency, nullptr, is_big_endian, width, height), + current_image_(current_image), + width_(width), + height_(height), + slot_(slot) {} + + void setup() override; + void dump_config() override; + + template void add_on_image_display_callback(F &&callback) { + this->image_display_callback_.add(std::forward(callback)); + } + template void add_on_image_clear_callback(F &&callback) { + this->image_clear_callback_.add(std::forward(callback)); + } + template void add_on_image_error_callback(F &&callback) { + this->image_error_callback_.add(std::forward(callback)); + } + + /// @brief Sets the optional view a transition draws the outgoing artwork from. + /// + /// It holds the outgoing frame while a transition is running and the current frame at any other + /// time, so it always names a picture and never the frame being decoded. + /// + /// Setting it is also what defers the library ack to transition_finished(): the ack releases the + /// outgoing frame to be decoded over, and this view is the only thing that still names it. + void set_transition_image(ArtworkImageView *transition_image) { this->transition_image_ = transition_image; } + + /// @brief Signals that the display transition for the last frame has finished. + /// + /// Acks the library so the next artwork can be delivered, which also hands the outgoing frame's + /// buffer over to be decoded into. Safe no-op when no transition is pending (e.g. no transition + /// image is configured, a clear already ended the transition, or the call is a duplicate). Must + /// run on the main loop thread; exposed as the sendspin.image.transition_finished action. + void transition_finished(); + + protected: + void on_decode_(const uint8_t *data, size_t length); + bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target); + void on_display_(uint32_t lateness_ms); + void on_clear_(); + void finish_transition_(); + void apply_frames_(bool transition_is_artwork); + void report_error_(); + + ArtworkDecodeSink decode_sink_; + + // The two frame buffers, allocated in setup() and never freed. Their contents are written on the + // decode thread and read by whatever draws the views, so only their roles are swapped, never the + // pointers themselves. + std::array buffers_{}; + + // pending_mutex_ guards the two fields below, the only state shared across threads. Everything + // after them is touched on the main loop only. + Mutex pending_mutex_; + // Index into buffers_ of the frame the current image shows. buffers_[current_index_ ^ 1] holds + // the outgoing frame and is the next decode target. Written on the main loop, read on the + // decode thread. + uint8_t current_index_{0}; + // Set on the decode thread once a frame is waiting in buffers_[current_index_ ^ 1]. + bool frame_pending_{false}; + + // True once artwork has been displayed, until the next clear; decides whether the outgoing frame + // is real artwork or the black the buffers start as. Main loop only. + bool showing_artwork_{false}; + // True while a displayed frame awaits transition_finished(); gates duplicate or stray calls + // so exactly one ack reaches the library per delivery. Main loop only. + bool transition_pending_{false}; + + ArtworkImageView *current_image_; + ArtworkImageView *transition_image_{nullptr}; + int width_; + int height_; + uint8_t slot_; + + LazyCallbackManager image_display_callback_{}; + LazyCallbackManager image_clear_callback_{}; + LazyCallbackManager image_error_callback_{}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04dbab0080..2d2f646382 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,12 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_SENDSPIN_ARTWORK +// Indexed by the library enums, which start at zero and are contiguous. +static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +#endif + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -37,6 +43,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_ARTWORK + this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_); + this->artwork_role_->set_listener(this); +#endif + #ifdef USE_SENDSPIN_CONTROLLER this->controller_role_ = &this->client_->add_controller(); this->controller_role_->set_listener(this); @@ -67,6 +78,18 @@ void SendspinHub::dump_config() { " Client ID: %s\n" " Task stack in PSRAM: %s", get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + +#ifdef USE_SENDSPIN_ARTWORK + // Slot indices come from the order the image platform entries were declared, so the log is the + // only place the mapping from a slot to the artwork it asked for can be read back. + uint8_t slot = 0; + for (const auto &preference : this->artwork_config_.preferred_formats) { + ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++, + IMAGE_SOURCE_NAMES[static_cast(preference.source)], + IMAGE_FORMAT_NAMES[static_cast(preference.format)], preference.width, preference.height, + preference.display_offset_ms); + } +#endif } // --- Delegating methods --- @@ -174,6 +197,30 @@ std::optional SendspinHub::load_last_server_hash() { // --- Sendspin role specific methods/overrides --- +#ifdef USE_SENDSPIN_ARTWORK +// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too +void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length, + sendspin::SendspinImageFormat format) { + this->artwork_image_decode_callbacks_.call(slot, data, length, format); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop() once the slot's offset-shifted display +// deadline is reached; lateness_ms reports how far past the deadline the display slipped) +void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) { + this->artwork_image_display_callbacks_.call(slot, lateness_ms); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop()) +void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); } + +// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented) +void SendspinHub::artwork_frame_done(uint8_t slot) { + if (this->artwork_role_ != nullptr) { + this->artwork_role_->frame_done(slot); + } +} +#endif + #ifdef USE_SENDSPIN_CONTROLLER // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c6b1ed97f7..a495fdcf37 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,9 @@ #include #include +#ifdef USE_SENDSPIN_ARTWORK +#include +#endif #ifdef USE_SENDSPIN_CONTROLLER #include #endif @@ -69,6 +72,9 @@ struct StaticDelayPref { /// (for services the library pulls; e.g., persistence, network readiness). /// - User -> library communication uses exposed functions on the client and role objects that the user calls. class SendspinHub final : public Component, +#ifdef USE_SENDSPIN_ARTWORK + public sendspin::ArtworkRoleListener, +#endif #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, #endif @@ -121,6 +127,27 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods --- +#ifdef USE_SENDSPIN_ARTWORK + void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; } + + /// @brief Acknowledges the most recent artwork delivery (display or clear) for a slot. + /// + /// Every slot is configured with the library's require_frame_done gate, which withholds the + /// next delivery for the slot until this is called. Exactly one ack is owed per delivery; a + /// redundant call is a safe no-op in the library. Must be called from the main loop thread. + void artwork_frame_done(uint8_t slot); + + template void add_image_decode_callback(F &&callback) { + this->artwork_image_decode_callbacks_.add(std::forward(callback)); + } + template void add_image_display_callback(F &&callback) { + this->artwork_image_display_callbacks_.add(std::forward(callback)); + } + template void add_image_clear_callback(F &&callback) { + this->artwork_image_clear_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_CONTROLLER void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); @@ -171,6 +198,23 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods/overrides/member variables --- +#ifdef USE_SENDSPIN_ARTWORK + void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override; + + void on_image_display(uint8_t slot, uint32_t lateness_ms) override; + + void on_image_clear(uint8_t slot) override; + + sendspin::ArtworkRoleConfig artwork_config_{}; + sendspin::ArtworkRole *artwork_role_{nullptr}; + + // Callback fan-out to child components; they filter by slot as needed. + CallbackManager + artwork_image_decode_callbacks_{}; + CallbackManager artwork_image_display_callbacks_{}; + CallbackManager artwork_image_clear_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_CONTROLLER sendspin::ControllerRole *controller_role_{nullptr}; diff --git a/tests/component_tests/sendspin/__init__.py b/tests/component_tests/sendspin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py new file mode 100644 index 0000000000..be3b7d6684 --- /dev/null +++ b/tests/component_tests/sendspin/test_image.py @@ -0,0 +1,114 @@ +"""Validation tests for the sendspin image platform. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data +from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _slot_config(**overrides: Any) -> ConfigType: + """Build a minimal valid artwork slot config, allowing field overrides.""" + config: ConfigType = { + "id": "album_slot", + "format": "JPEG", + "type": "RGB565", + "resize": "240x240", + "current_image": {"id": "album_art"}, + } + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config()) + + assert config["slot"] == 0 + assert config["source"] == "ALBUM" + assert config["display_offset"].total_milliseconds == 0 + + +@pytest.mark.parametrize("image_format", ["JPEG", "JPG"]) +def test_jpeg_alias_maps_to_one_enum( + set_core_config: SetCoreConfigCallable, image_format: str +) -> None: + """runtime_image takes JPG as an alias for JPEG, so both spellings must reach the + library's single JPEG enum.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_slot_config(format=image_format)) + + assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG + + +def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" + set_core_config(PlatformFramework.ESP32_IDF) + + for slot in range(MAX_ARTWORK_SLOTS): + assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot + + with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"): + CONFIG_SCHEMA(_slot_config(id="one_too_many")) + + +@pytest.mark.parametrize( + "resize", + [f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"], +) +def test_oversized_resize_rejected( + set_core_config: SetCoreConfigCallable, resize: str +) -> None: + """Either dimension past the decoder's limit is refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"): + CONFIG_SCHEMA(_slot_config(resize=resize)) + + +def test_sub_millisecond_display_offset_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The library field is whole milliseconds, so finer values are refused + rather than silently rounded down to zero.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"): + CONFIG_SCHEMA(_slot_config(display_offset="500us")) + + +@pytest.mark.parametrize("display_offset", ["61s", "-61s"]) +def test_out_of_range_display_offset_rejected( + set_core_config: SetCoreConfigCallable, display_offset: str +) -> None: + """Offsets more than a minute either side of the boundary are refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="value must be at (most|least)"): + CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + +@pytest.mark.parametrize( + ("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)] +) +def test_display_offset_accepted( + set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int +) -> None: + """Whole-millisecond offsets pass through in both directions.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + assert config["display_offset"].total_milliseconds == expected_ms diff --git a/tests/components/sendspin/common-image.yaml b/tests/components/sendspin/common-image.yaml new file mode 100644 index 0000000000..7c32a5e257 --- /dev/null +++ b/tests/components/sendspin/common-image.yaml @@ -0,0 +1,47 @@ +packages: + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(album_art)); + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + - logger.log: + format: "Album art displayed (late by %u ms)" + args: ["(unsigned) lateness_ms"] + # Stand-in for a display transition; with a transition image every display must end + # with transition_finished so the library releases the next artwork frame. + - delay: 300ms + - sendspin.image.transition_finished: album_slot + on_image_clear: + - logger.log: "Album art cleared" + on_image_error: + - logger.log: "Album art error" + - platform: sendspin + id: artist_slot + format: PNG + type: RGB565 + resize: 96x96 + source: ARTIST + current_image: + id: artist_art diff --git a/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml new file mode 100644 index 0000000000..9084d77262 --- /dev/null +++ b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml @@ -0,0 +1,66 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + auto_clear_enabled: false + +lvgl: + displays: + - main_lcd + animations: + # Fades the top widget out to reveal the new artwork underneath. Starting it also snaps the + # widget back to full opacity, and on_stop acks the transition so the library can deliver the + # next artwork. + - id: album_art_crossfade + duration: 2s + widgets: + - id: outgoing_art + opa: + from: 100% + to: 0% + on_stop: + - sendspin.image.transition_finished: album_slot + widgets: + # Cross-fade pair: the bottom widget always shows the current artwork; the top widget is + # pointed at the outgoing frame on each display event and faded out over it. + - image: + id: incoming_art + src: album_art + - image: + id: outgoing_art + src: album_art + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + # Start the fade 1s before the track boundary so the 2s cross-fade straddles it. + display_offset: 1s + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + # A widget keeps drawing the buffer it was last pointed at until its source is set again, so + # both widgets are re-pointed on every display: the top widget at the outgoing frame + # (covering the bottom), the bottom widget at the new frame. The transition image is black + # before the first artwork, so the first fade needs no special case. + - lvgl.image.update: + id: outgoing_art + src: album_art_transition + - lvgl.image.update: + id: incoming_art + src: album_art + - lvgl.animation.start: album_art_crossfade diff --git a/tests/components/sendspin/test-image.esp32-idf.yaml b/tests/components/sendspin/test-image.esp32-idf.yaml new file mode 100644 index 0000000000..a4f9e492c6 --- /dev/null +++ b/tests/components/sendspin/test-image.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common-image.yaml From e0b112c584ed91b799308840f935f139c11e4a86 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 17:03:27 -0400 Subject: [PATCH 105/597] [sendspin] Clear metadata and controller state on disconnect (#18289) Co-authored-by: J. Nick Koston --- .../media_player/sendspin_media_player.cpp | 22 ++++++++++--- .../media_player/sendspin_media_player.h | 3 ++ esphome/components/sendspin/sendspin_hub.cpp | 12 +++++++ esphome/components/sendspin/sendspin_hub.h | 19 +++++++++-- .../sendspin/sensor/sendspin_sensor.cpp | 26 ++++++++++++--- .../text_sensor/sendspin_text_sensor.cpp | 32 +++++++++---------- 6 files changed, 87 insertions(+), 27 deletions(-) diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index beb2028689..fe0bda6f42 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -34,11 +34,7 @@ void SendspinMediaPlayer::setup() { new_state = media_player::MEDIA_PLAYER_STATE_IDLE; break; } - if (this->state != new_state) { - this->state = new_state; - this->publish_state(); - ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); - } + this->set_playback_state_(new_state); } }); @@ -52,11 +48,27 @@ void SendspinMediaPlayer::setup() { } }); + // The connection dropped, so nothing is playing. The server never gets to send a final "stopped" group update, so + // without this the entity keeps reporting playing indefinitely. Volume and mute keep their last values, since + // media_player has no way to express an unknown volume. + this->parent_->add_controller_state_clear_callback( + [this]() { this->set_playback_state_(media_player::MEDIA_PLAYER_STATE_IDLE); }); + // Publish an initial state this->state = media_player::MEDIA_PLAYER_STATE_IDLE; this->publish_state(); } +// THREAD CONTEXT: Main loop (called from the callbacks registered in setup()) +void SendspinMediaPlayer::set_playback_state_(media_player::MediaPlayerState new_state) { + if (this->state == new_state) { + return; + } + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); +} + // THREAD CONTEXT: Main loop (invoked by the media_player framework) media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { auto traits = media_player::MediaPlayerTraits(); diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 651e1562be..ff76473189 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -25,6 +25,9 @@ class SendspinMediaPlayer final : public SendspinChild, public media_player::Med // Receives commands from HA void control(const media_player::MediaPlayerCall &call) override; + /// @brief Publishes @p new_state if it differs from the current state. + void set_playback_state_(media_player::MediaPlayerState new_state); + float volume_increment_{0.05f}; bool muted_{false}; }; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2d2f646382..028491284a 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -239,6 +239,12 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); } + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +// Unlike metadata, this cannot be fanned out as a default-constructed state object: volume and muted are plain values +// rather than optionals, so children would read a real-looking 0% volume where we mean no value at all. A separate +// callback lets each child clear only what it can represent. +void SendspinHub::on_controller_state_clear() { this->controller_state_clear_callbacks_.call(); } #endif #ifdef USE_SENDSPIN_METADATA @@ -247,6 +253,12 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat this->metadata_update_callbacks_.call(metadata); } +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +// The cached metadata was dropped because the connection to the server was lost, so what the children now mirror is +// the empty state. Fanning that out as a default-constructed state object rather than through a separate callback +// keeps one code path in the children: every field is nullopt, which they already publish as empty/unknown. +void SendspinHub::on_metadata_clear() { this->metadata_update_callbacks_.call(sendspin::ServerMetadataStateObject{}); } + // THREAD CONTEXT: Main loop (invoked from Sendspin components) uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index a495fdcf37..7c50c3eb80 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -155,9 +155,18 @@ class SendspinHub final : public Component, template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } + + /// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped. + template void add_controller_state_clear_callback(F &&callback) { + this->controller_state_clear_callbacks_.add(std::forward(callback)); + } #endif #ifdef USE_SENDSPIN_METADATA + /// @brief Registers a callback that fires when the server sends metadata. + /// + /// Also fires when the connection is lost, with an all-empty state object (every field nullopt, timestamp 0) meaning + /// the cached metadata was dropped. Subscribers must treat an absent field as cleared, not as no update. template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } @@ -220,8 +229,12 @@ class SendspinHub final : public Component, void on_controller_state(const sendspin::ServerStateControllerObject &state) override; - // Callback fan-out to child components; they filter as needed - CallbackManager controller_state_callbacks_{}; + void on_controller_state_clear() override; + + // Callback fan-out to child components; they filter as needed. Only a media_player subscribes, while the switch + // action and the media source enable the controller role without one, so keep the idle cost to a single pointer. + LazyCallbackManager controller_state_callbacks_{}; + LazyCallbackManager controller_state_clear_callbacks_{}; #endif #ifdef USE_SENDSPIN_METADATA @@ -229,6 +242,8 @@ class SendspinHub final : public Component, void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + void on_metadata_clear() override; + // Callback fan-out to child components; they filter as needed CallbackManager metadata_update_callbacks_{}; #endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 68848a6f3e..dcbab75b65 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -4,6 +4,8 @@ #include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.sensor"; @@ -20,6 +22,13 @@ void SendspinTrackProgressSensor::dump_config() { void SendspinTrackProgressSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { if (!metadata.progress.has_value()) { + // Progress is unknown: the server has not reported it, or it was cleared (e.g. on disconnect). Stop polling and + // report unknown rather than leaving the last position frozen on the frontend. Only the transition is published; + // NAN never compares equal to itself, so an unguarded publish would repeat on every metadata update. + this->stop_poller(); + if (!std::isnan(this->get_raw_state())) { + this->publish_state(NAN); + } return; } const auto &progress = metadata.progress.value(); @@ -34,6 +43,11 @@ void SendspinTrackProgressSensor::setup() { this->start_poller(); } }); + + // PollingComponent starts the poller before setup(), but there is nothing to interpolate yet: + // get_track_progress_ms() returns 0 until the server reports a position, so polling now would publish 0 every tick + // from boot until the first metadata arrives. The callback above starts it once playback is running. + this->stop_poller(); } // THREAD CONTEXT: Main loop. @@ -80,15 +94,19 @@ std::optional SendspinMetadataSensor::extract_value_(const sendspin::Serv // (SendspinHub dispatches metadata from client_->loop()). void SendspinMetadataSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (auto value = this->extract_value_(metadata)) { - this->publish_if_changed_(*value); - } + // A field the server has not provided, or has explicitly cleared, is published as NAN (the sensor convention for + // unknown) rather than skipped, so a value that goes away does not linger from the previous track. + this->publish_if_changed_(this->extract_value_(metadata).value_or(NAN)); }); } // Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. void SendspinMetadataSensor::publish_if_changed_(float value) { - if (this->get_raw_state() != value) { + const float current = this->get_raw_state(); + // The raw state starts as NAN, so a field that is already cleared when the first update arrives is suppressed here + // as well: the frontend still shows the sensor as unknown, which is what a clear means. NAN never compares equal to + // itself, so a field that stays cleared would republish on every metadata update without the second check. + if (current != value && !(std::isnan(current) && std::isnan(value))) { this->publish_state(value); } } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 9843fb966e..554e01cf88 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -12,40 +12,40 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to +// show, so return the empty string and let the caller publish it; returning early would leave the previous track's +// value on display. +// +// The empty string is not the same as unknown. A text sensor reports unknown through the API's missing_state flag, +// which follows has_state(), and has_state() is only ever set, never cleared. Once a real value has been published, +// an empty state is the closest we can get. The numeric sensors publish NAN, which does read as unknown. const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { switch (this->metadata_type_) { case SendspinTextMetadataTypes::TITLE: - if (metadata.title.has_value()) - return metadata.title.value().c_str(); - return nullptr; + return metadata.title.has_value() ? metadata.title.value().c_str() : ""; case SendspinTextMetadataTypes::ARTIST: - if (metadata.artist.has_value()) - return metadata.artist.value().c_str(); - return nullptr; + return metadata.artist.has_value() ? metadata.artist.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM: - if (metadata.album.has_value()) - return metadata.album.value().c_str(); - return nullptr; + return metadata.album.has_value() ? metadata.album.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM_ARTIST: - if (metadata.album_artist.has_value()) - return metadata.album_artist.value().c_str(); - return nullptr; + return metadata.album_artist.has_value() ? metadata.album_artist.value().c_str() : ""; } - return nullptr; + return ""; } // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). void SendspinTextSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (const char *value = this->extract_value_(metadata)) { - this->publish_if_changed_(value); - } + this->publish_if_changed_(this->extract_value_(metadata)); }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. void SendspinTextSensor::publish_if_changed_(const char *value) { + // The state starts empty, so a field that is already cleared when the first update arrives is suppressed here: the + // entity stays unknown rather than being dropped out of it for good by an empty publish. Later clears do publish the + // empty string and fire on_value with it. if (this->get_raw_state() != value) { this->publish_state(value); } From 3540012529473103e8c684688d4061107b2bbca4 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 11 Aug 2026 16:09:51 -0700 Subject: [PATCH 106/597] [tests] Build tests from the tree they run in, not the venv's editable install (#18248) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- pyproject.toml | 3 +++ tests/integration/conftest.py | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 166b3cf6bb..afa6208cae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ include = ["esphome*"] testpaths = [ "tests", ] +# Prepend the repo root so in-process esphome imports resolve to THIS tree, +# not wherever the venv's editable install points (e.g. another git worktree). +pythonpath = ["."] addopts = [ "--cov=esphome", "--cov-branch", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a9c9e0686f..1bf799b658 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -63,6 +63,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" + # Compile with THIS tree's esphome sources, not wherever the venv's editable + # install points (which may be a different git worktree or checkout). + repo_root = str(Path(__file__).resolve().parent.parent.parent) + existing = env.get("PYTHONPATH") + env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root return env @@ -101,7 +106,7 @@ def shared_platformio_cache() -> Generator[Path]: env = _get_platformio_env(cache_dir) subprocess.run( - ["esphome", "compile", str(config_path)], + [sys.executable, "-m", "esphome", "compile", str(config_path)], check=True, cwd=init_dir, env=env, @@ -245,6 +250,8 @@ async def compile_esphome( for attempt in range(max_retries): # Compile using subprocess, inheriting stdout/stderr to show progress proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", "esphome", "compile", str(config_path), From 9556c2bc4c4c77871a2e48d631a2f78a452c7232 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 12 Aug 2026 01:39:05 +0200 Subject: [PATCH 107/597] [mitsubishi_cn105] Add vertical vane control action (#16737) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 99 ++++++++++++++++++- .../components/mitsubishi_cn105/automation.h | 19 ++++ .../mitsubishi_cn105_component.cpp | 7 ++ .../mitsubishi_cn105_component.h | 32 +++++- .../mitsubishi_cn105/select/__init__.py | 6 +- .../mitsubishi_cn105_vane_select_vertical.cpp | 2 +- tests/components/mitsubishi_cn105/common.h | 1 + tests/components/mitsubishi_cn105/common.yaml | 8 ++ .../mitsubishi_cn105_component_tests.cpp | 33 ++++++- 9 files changed, 190 insertions(+), 17 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 70ed0a7a85..450d1cd222 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -2,9 +2,15 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL -from esphome.core import ID -from esphome.cpp_generator import MockObj +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_ON_STATE, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) +from esphome.core import ID, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@crnjan"] @@ -14,6 +20,7 @@ DOMAIN = "mitsubishi_cn105" CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" CONF_VANE = "vane" +CONF_VERTICAL = "vertical" mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) @@ -24,6 +31,20 @@ MitsubishiCN105Component = mitsubishi_ns.class_( ) VaneState = mitsubishi_ns.struct("VaneState") +VaneCall = mitsubishi_ns.class_("VaneCall") +VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") + +# The insertion order must match VALUES in +# select/mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = { + "AUTO": VerticalVaneMode.VERTICAL_VANE_MODE_AUTO, + "1": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_1, + "2": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_2, + "3": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_3, + "4": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_4, + "5": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_5, + "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, +} SetRemoteTemperatureAction = mitsubishi_ns.class_( "SetRemoteTemperatureAction", @@ -37,6 +58,11 @@ ClearRemoteTemperatureAction = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) +VaneControlAction = mitsubishi_ns.class_( + "VaneControlAction", + automation.Action, +) + CONFIG_SCHEMA = ( cv.Schema( { @@ -152,3 +178,70 @@ async def clear_temperature_action_to_code( var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var + + +VANE_CONTROL_FIELDS = ( + ( + (CONF_VERTICAL, CONF_DIRECTION), + "vertical.set_direction", + VerticalVaneMode, + ), +) + +VANE_CONTROL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Optional(CONF_VERTICAL): cv.Schema( + { + cv.Optional(CONF_DIRECTION): cv.templatable( + cv.enum(VERTICAL_VANE_DIRECTIONS, upper=True) + ), + } + ), + } +) + + +@automation.register_action( + f"{DOMAIN}.vane.control", + VaneControlAction, + VANE_CONTROL_ACTION_SCHEMA, + synchronous=True, +) +async def vane_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + cg.add_global(mitsubishi_ns.using) + parent = await cg.get_variable(config[CONF_ID]) + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) + for t, name in args + ] + forwarded_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for path, setter, type_ in VANE_CONTROL_FIELDS: + if (section := config.get(path[0])) is None: + continue + if (value := section.get(path[1])) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda( + value, + normalized_args, + return_type=type_, + ) + body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + [(VaneCall.operator("ref"), "call"), *normalized_args], + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h index 879e556f9c..2fc6ba3c32 100644 --- a/esphome/components/mitsubishi_cn105/automation.h +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -4,6 +4,8 @@ #include "esphome/core/automation.h" +#include + namespace esphome::mitsubishi_cn105 { template @@ -20,4 +22,21 @@ class ClearRemoteTemperatureAction : public Action, public Parentedparent_->clear_remote_temperature(); } }; +template class VaneControlAction : public Action { + public: + using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); + + VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} + + void play(const Ts &...x) override { + auto call = this->parent_->make_vane_call(); + this->apply_(call, x...); + call.perform(); + } + + protected: + MitsubishiCN105Component *parent_; + ApplyFn apply_; +}; + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 5314965af6..8e9e954645 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -31,4 +31,11 @@ void MitsubishiCN105Component::loop() { } } +void VaneCall::perform() { + if (const auto &direction = this->vertical.get_direction(); direction.has_value()) { + this->parent_->set_vane_mode(static_cast(*direction)); + } + this->parent_->publish_status(); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 64077432fd..6461fb464b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -6,6 +6,7 @@ #include "esphome/components/uart/uart.h" #include +#include namespace esphome::mitsubishi_cn105 { @@ -17,6 +18,7 @@ enum VerticalVaneMode : uint8_t { VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), + VERTICAL_VANE_MODE_UNKNOWN = static_cast(MitsubishiCN105::VaneMode::UNKNOWN), }; struct VaneState { @@ -27,6 +29,27 @@ struct VaneState { Vertical vertical; }; +class MitsubishiCN105Component; + +struct VaneCall { + struct Vertical { + void set_direction(VerticalVaneMode direction) { this->direction_ = direction; } + const std::optional &get_direction() const { return this->direction_; } + + protected: + std::optional direction_; + }; + + explicit VaneCall(MitsubishiCN105Component *parent) : parent_(parent) {} + + Vertical vertical; + + void perform(); + + protected: + MitsubishiCN105Component *parent_; +}; + class MitsubishiCN105Component : public Component, public uart::UARTDevice { public: explicit MitsubishiCN105Component() : hp_(*this) {} @@ -47,6 +70,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + VaneCall make_vane_call() { return VaneCall(this); } const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } @@ -69,11 +93,9 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { protected: void notify_status_listeners_() { this->status_callback_.call(); - if (this->status().vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { - this->vane_state_callback_.call(VaneState{ - .vertical = {.direction = static_cast(this->status().vane_mode)}, - }); - } + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); } MitsubishiCN105 hp_; diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py index a2e0353f85..4ca12edbb4 100644 --- a/esphome/components/mitsubishi_cn105/select/__init__.py +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType from .. import ( MITSUBISHI_CN105_DEVICE_SCHEMA, + VERTICAL_VANE_DIRECTIONS, MitsubishiCN105Component, mitsubishi_ns, register_mitsubishi_cn105_device, @@ -15,9 +16,6 @@ DEPENDENCIES = ["mitsubishi_cn105"] CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" -# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp. -VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"] - MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( "MitsubishiCN105VerticalVaneDirectionSelect", select.Select, @@ -42,6 +40,6 @@ async def to_code(config: ConfigType) -> None: await select.register_select( var, vertical_vane_direction, - options=VERTICAL_VANE_DIRECTIONS, + options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS], ) await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp index 0f9142fe5e..d703ddbb02 100644 --- a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -4,7 +4,7 @@ namespace esphome::mitsubishi_cn105 { -// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py. +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in the hub's __init__.py. // MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based // Select API, so Python option order and this array must stay aligned. static constexpr std::array VALUES{ diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index a119a38d24..f542880eef 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/automation.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index fcd1b048dd..fc14724786 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -29,3 +29,11 @@ esphome: temperature: 22.0 - mitsubishi_cn105.clear_remote_temperature: id: ac + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: SWING + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp index 48ea6b0c29..c957759223 100644 --- a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -24,25 +24,50 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) { EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); } -TEST(MitsubishiCN105ComponentTests, DoesNotPublishUnknownVaneState) { +TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) { TestableMitsubishiCN105Component hub; size_t status_callback_count = 0; size_t vane_callback_count = 0; + std::optional callback_direction; hub.add_on_status_callback([&]() { status_callback_count++; }); - hub.add_on_vane_state_callback([&](const VaneState &) { vane_callback_count++; }); + hub.add_on_vane_state_callback([&](const VaneState &state) { + vane_callback_count++; + callback_direction = state.vertical.direction; + }); hub.mutable_status().room_temperature = 20.0f; hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; hub.publish_status(); EXPECT_EQ(status_callback_count, 1); - EXPECT_EQ(vane_callback_count, 0); + EXPECT_EQ(vane_callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN}); hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; hub.publish_status(); EXPECT_EQ(status_callback_count, 2); - EXPECT_EQ(vane_callback_count, 1); + EXPECT_EQ(vane_callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { + TestableMitsubishiCN105Component hub; + + auto call = hub.make_vane_call(); + call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5); + call.perform(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); +} + +TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { + TestableMitsubishiCN105Component hub; + VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); + + action.play(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); } } // namespace esphome::mitsubishi_cn105::testing From 37a59a07bce1947604324e875bcd3da3bb15f54a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 18:40:47 -0500 Subject: [PATCH 108/597] [bluetooth_proxy] Retry dropped GATT acks and stop the retry log spam (#18259) --- .../bluetooth_connection.h | 4 + .../bluetooth_connection_bluedroid.cpp | 6 +- .../bluetooth_connection_hub.cpp | 137 +++++++++++++++--- .../bluetooth_connection_hub.h | 76 +++++++++- .../bluetooth_proxy/bluetooth_proxy.cpp | 21 ++- .../bluetooth_proxy/bluetooth_proxy.h | 3 +- 6 files changed, 212 insertions(+), 35 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 53e319e369..d200c6b48f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -92,6 +92,10 @@ static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVIC // delivered near the client's 30 s timeout could land on a fresh request's // empty accumulator and cache as an empty database. static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; +// Owed-ack retries stop after ~25 s of subscribed drain time from the first +// refusal, keeping most of the client's 30 s GATT window for congestion to +// clear while still bounding how stale a delivered reply can be. +static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250; // ---- Service-streaming size budget, shared by every platform's streamer ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index d6b815fc2e..99a6a312ec 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -397,6 +397,8 @@ void BluedroidGattClient::deliver_pending_search_() { // which proxy builds compile without a materializer. static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); +// Bound by the SERVICE STREAMING HAZARD note at the top of +// bluetooth_connection_hub.cpp: never skip a batch, never send done early. void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { if (this->services_released_) { // Released under the stream: park without services-done so a partial @@ -527,9 +529,11 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { // On a failed send, rewind the cursor so the batch is retried instead of // silently skipped. if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_); + conn.note_batch_stalled_(); conn.send_service_ = batch_start; + return; } + conn.batch_stalled_ = false; } #endif // USE_BLUETOOTH_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index c43b2a6f7c..3909e16305 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,4 +1,24 @@ // The proxy's per-slot connection wrapper, shared by every platform. +// +// SERVICE STREAMING HAZARD - read before touching the streaming code here or +// in the platform streamers (bluetooth_connection_bluedroid.cpp). +// +// A V3 client caches the service list it receives as the device's complete, +// permanent database. Nothing on the wire marks a list as partial, so a +// stream that is truncated, has a skipped batch, or is terminated early +// would be cached whole and poison every later session with the device. +// +// The rule: it is always better to send nothing and let the client time out +// than to let services-done follow an incomplete stream. Concretely: +// - a refused batch rewinds the cursor and is retried, never skipped; +// - services-done is sent only after every batch was accepted; +// - every interruption (subscriber lost or swapped, backend abort, +// bounds-check failure) parks or aborts WITHOUT services-done and drops +// any owed done; +// - a new GetServices supersedes an owed done, so a stale done can never +// land on a fresh request's empty accumulator and cache it as empty. +// The client only caches a list terminated by services-done within the same +// request; timeouts, disconnects and errors raise instead of caching. #include "bluetooth_connection_hub.h" #ifdef BLUETOOTH_CONNECTION_HAS_GATT @@ -16,6 +36,9 @@ static const char *const TAG = "bluetooth_connection"; void BluetoothConnection::set_address(uint64_t address) { // Keep the proxy's pre-allocated connections-free message in step this->proxy_->update_address_slot_(this->address_, address); + // Slot changing hands: anything owed belonged to the old address. The + // choke point for every reassignment, not just reset_connection_()'s path. + this->clear_pending_ack_(); this->address_ = address; if (address == 0) { this->address_str_[0] = '\0'; @@ -73,6 +96,9 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->state_ = ClientState::IDLE; this->services_discovered_ = false; this->paired_ = false; + // Link gone: the slot may hold a different device before the drain runs. + this->clear_pending_ack_(); + this->batch_stalled_ = false; this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } @@ -163,13 +189,85 @@ void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint1 operation, handle, status); } +void BluetoothConnection::note_batch_stalled_() { + if (this->batch_stalled_) + return; + this->batch_stalled_ = true; + ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_, + this->address_str_); +} + +/// Both payload-free acks are just (address, handle); only the type differs. +template +static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) { + Response resp; + resp.address = address; + resp.handle = handle; + return api_connection->send_message(resp); +} + +/// Sole construction site, so a re-offer cannot drift from the original. +bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (kind == PendingAck::PENDING_ACK_ERROR) { + // Proxy owns the error reply and reports a refusal the same way. + return this->proxy_->send_gatt_error(this->address_, handle, error); + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return true; // Nobody subscribed: nothing is owed + switch (kind) { + case PendingAck::PENDING_ACK_WRITE: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NOTIFY: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NONE: + case PendingAck::PENDING_ACK_ERROR: // returned above + return true; + } + // No default label above, so a new enumerator is a -Wswitch warning rather + // than a silent notify reply. This return only satisfies -Wreturn-type. + return true; +} + +void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (this->try_send_ack_(kind, handle, error)) + return; + // Report a newly owed reply and a displaced one; displacing is the case + // that loses a reply. Re-refusing the same one stays quiet. + if (!this->has_pending_ack_()) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_, + this->address_str_, handle); + } else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_, + this->address_str_, this->pending_ack_handle_, handle); + } + this->latch_pending_ack_(kind, handle, error); +} + +void BluetoothConnection::flush_pending_ack_() { + // No-op on its own rather than relying on the proxy drain's pre-check. + if (!this->has_pending_ack_()) + return; + if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { + this->clear_pending_ack_(); + return; + } + if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { + // Undeliverable: past here the client has given up and may have re-asked, + // and a late reply would answer the new request instead of this one. + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_, + this->address_str_, this->pending_ack_handle_); + this->clear_pending_ack_(); + } +} + void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { // Late completion for a freed slot; nothing to report. if (this->address_ == 0) return; if (error != 0) { this->log_gatt_operation_error_("reading char/descriptor", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } auto *api_connection = this->proxy_->get_api_connection(); @@ -180,6 +278,8 @@ void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, u resp.handle = handle; resp.set_data(data, len); if (!api_connection->send_message(resp)) { + // Not latched: would mean holding the payload through the congestion + // that refused it. The client's read timeout arbitrates. ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); } } @@ -189,18 +289,10 @@ void BluetoothConnection::on_write_result(uint16_t handle, int error) { return; if (error != 0) { this->log_gatt_operation_error_("writing char/descriptor", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - return; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = handle; - if (!api_connection->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_); - } + this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle); } void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { @@ -209,18 +301,10 @@ void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int err if (error != 0) { this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - return; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = handle; - if (!api_connection->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_); - } + this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle); } void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { @@ -235,6 +319,8 @@ void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, u resp.handle = handle; resp.set_data(data, len); if (!api_connection->send_message(resp)) { + // Not latched, same reason as the read reply. Notify data is lossy: the + // peripheral will not resend it. ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); } } @@ -251,6 +337,7 @@ conn_err_t BluetoothConnection::check_connected_op_(const char *action, const ch } conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -259,6 +346,7 @@ conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -266,6 +354,7 @@ conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint } conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -276,6 +365,7 @@ conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { // the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool /*response*/) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -283,6 +373,7 @@ conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t } conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY); if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, @@ -413,9 +504,11 @@ void BluetoothConnection::send_service_for_discovery_() { // (bounded: a subscriber that stays gone ends streaming via the api-lost // rewind above). if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->note_batch_stalled_(); this->send_service_ = batch_start; + return; } + this->batch_stalled_ = false; } } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index d964af5530..b0d0f5fd46 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -25,6 +25,16 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; +/// A refused GATT reply owed to the current subscriber. Payload-free only: +/// these rebuild from address + handle + error, so a retry costs no buffered +/// data. Read and notify-data carry payloads and are deliberately absent. +enum class PendingAck : uint8_t { + PENDING_ACK_NONE = 0, + PENDING_ACK_WRITE, + PENDING_ACK_NOTIFY, + PENDING_ACK_ERROR, +}; + class BluetoothConnection final : public ble_device_base::GattClientListener { public: /// Wire the platform backend. Called from codegen before setup. @@ -116,6 +126,42 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->pending_error_ = err; } } + + /// Latch a refused reply for the proxy drain. One slot per connection, + /// newest wins: a GATT client works one request at a time, and a discarded + /// reply falls back to the timeout it would have hit anyway. + void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) { + this->pending_ack_retries_ = 0; + this->pending_ack_ = kind; + this->pending_ack_handle_ = handle; + this->pending_ack_error_ = error; + } + void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; } + /// Drop an owed reply this re-ask makes stale. Clients match futures on + /// response type as well as handle, so an owed error (which resolves any op + /// on the handle) is cleared by any re-ask, other kinds only by their own. + void supersede_pending_ack_(uint16_t handle, PendingAck kind) { + if (this->has_pending_ack_() && this->pending_ack_handle_ == handle && + (this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) { + this->clear_pending_ack_(); + } + } + bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; } + /// Warn on the stall's leading edge only. The batch is never lost (the + /// caller rewinds the cursor), and a warning per attempt would add traffic + /// to the connection already refusing frames. Both streamers route here. + void note_batch_stalled_(); + /// Sole construction site for these replies, shared by send and retry. + bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); + /// First attempt: send, and latch it for the drain if the API refuses. + void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0); + /// Report a rejected request. Latched like a completion reply, so a + /// refused frame does not strand the client for its whole timeout. + void send_gatt_error_(uint16_t handle, conn_err_t error) { + this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error); + } + /// Re-offer the owed reply; clears on success, stays owed on a refusal. + void flush_pending_ack_(); // A backend providing its own streamer (see the contract doc) builds the // response in place from its stack cache; the rest use the table streamer. // Template so the discarded branch is not odr-checked against backends @@ -131,6 +177,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// interrupted stream must never be declared complete (the client's /// timeout arbitrates), and an owed done is dropped with it. void park_service_stream_() { + // Agree with reset_connection_(): a stall flag left set would swallow the + // next session's leading-edge warning. + this->batch_stalled_ = false; if (this->send_service_ >= 0) { this->backend_->release_services(); this->send_service_ = DONE_SENDING_SERVICES; @@ -153,23 +202,31 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; ble_device_base::BLEGattConnection *backend_{nullptr}; - // Group 2: 2-byte types + // Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays + // 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8). int16_t send_service_{INIT_SENDING_SERVICES}; uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // Group 3: 8-byte and 4-byte types uint64_t address_{0}; conn_err_t pending_error_{0}; + // Full width: the GATT error domain is open-ended (ble_gatt_client.h) and + // forwarded untranslated, so narrowing would corrupt platform codes. + conn_err_t pending_ack_error_{0}; // Group 4: Arrays char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + // Parked here rather than in Group 2: address_str_ ends 2-aligned, so this + // uses tail slack instead of pushing address_ out by 6 bytes of padding. + uint16_t pending_ack_handle_{0}; - // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + // Group 5: bit-packed tail. pending_ack_error_ takes the 8-aligned object + // from 48 to 56, so the third tail byte is free; first two stay packed. static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); // Ordered so neither byte's fields straddle a storage unit: 3+5 and - // 4+2+1+1 fill the two tail bytes exactly. + // 4+2+1+1 fill the first two tail bytes exactly. ClientState state_ : 3 {ClientState::IDLE}; static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); uint8_t services_done_retries_ : 5 {0}; @@ -177,8 +234,21 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { ConnectionType connection_type_ : 2 {ConnectionType::V1}; bool paired_ : 1 {false}; bool services_discovered_ : 1 {false}; + static_assert(static_cast(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow"); + PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; + /// Set while a refused batch is retrying, so only the first one warns. + bool batch_stalled_ : 1 {false}; + // Plain byte after the bitfields: takes the padding byte instead of + // straddling pending_ack_'s storage unit and growing the object. + static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); + uint8_t pending_ack_retries_{0}; }; +// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad +// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit. +static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, + "BluetoothConnection layout regressed on a 32-bit target"); + } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 13c84b86d1..ff5b7bc5cb 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -387,7 +387,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms auto err = connection->read_characteristic(msg.handle); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -400,7 +400,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -413,7 +413,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead auto err = connection->read_descriptor(msg.handle); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -426,7 +426,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -477,7 +477,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest auto err = connection->notify_characteristic(msg.handle, msg.enable); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -597,6 +597,9 @@ void BluetoothProxy::loop() { if (connection->send_service_ == SERVICES_DONE_PENDING) { connection->send_services_done_(); } + if (connection->has_pending_ack_()) { + connection->flush_pending_ack_(); + } auto &owed = this->pending_disconnections_[i]; if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { owed.clear(); @@ -716,6 +719,8 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection // Neither a partial stream's tail nor an owed done belongs to the new // session; silence (the client's timeout) arbitrates. this->connections_[i]->park_service_stream_(); + // An ack owed to the previous subscriber means nothing to the new one. + this->connections_[i]->clear_pending_ack_(); } this->pending_disconnections_.fill({}); #endif @@ -776,14 +781,14 @@ bool BluetoothProxy::send_gatt_services_done(uint64_t address) { return this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { +bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTErrorResponse call; call.address = address; call.handle = handle; call.error = error; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index cf7a09a7e5..0b51d61c60 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -145,7 +145,8 @@ class BluetoothProxy final : public Component { void send_connections_free(api::APIConnection *api_connection); /// Same convention as send_device_connection: false only on a refused frame. bool send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + /// False only when the API refused the frame, so the reply is still owed. + bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); From 201f843e95a98f35f0185661dc504a72d88f421b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 18:57:51 -0500 Subject: [PATCH 109/597] [bluetooth_proxy] Latch the unpair reply (#18274) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 45 ++++++++++++++++++- .../bluetooth_proxy/bluetooth_proxy.h | 34 ++++++++------ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ff5b7bc5cb..4c4cbf27a2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -197,7 +197,7 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { // Match before free entry so one address never occupies two pool slots. - PendingDisconnect *free_entry = nullptr; + PendingReply *free_entry = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { auto &owed = this->pending_disconnections_[i]; if (owed.matches(address)) { @@ -605,6 +605,13 @@ void BluetoothProxy::loop() { owed.clear(); } } + + // An owed unpair reply. Not pre-cleared: the sender clears on success and + // re-latches on refusal, keeping its leading-edge warn guard honest. + if (!this->pending_unpairing_.empty()) { + conn_err_t error = this->pending_unpairing_.error(); + this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error); + } #endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK @@ -715,6 +722,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection // re-subscribe by the current one keeps what it is still owed. this->connections_free_pending_ = false; #ifdef BLUETOOTH_CONNECTION_HAS_GATT + this->pending_unpairing_.clear(); for (uint8_t i = 0; i < this->connection_count_; i++) { // Neither a partial stream's tail nor an owed done belongs to the new // session; silence (the client's timeout) arbitrates. @@ -741,6 +749,9 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti } this->api_connection_ = nullptr; this->connections_free_pending_ = false; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + this->pending_unpairing_.clear(); +#endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK this->scanner_state_pending_ = false; #endif @@ -805,12 +816,42 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // An owed success is the authoritative answer: a later attempt for the + // same address fails only because the first already removed the bond. + if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && + this->pending_unpairing_.error() == CONN_OK) { + success = true; + error = CONN_OK; + } +#endif api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - this->api_connection_->send_message(call); + // Advertisement-only builds answer this with a canned reply and keep no + // retry state, so only the latch is conditional, not the send. + [[maybe_unused]] bool sent = this->api_connection_->send_message(call); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + if (sent) { + // A later unpair landing for an address that still has one owed would + // otherwise have the drain repeat it. + if (this->pending_unpairing_.matches(address)) { + this->pending_unpairing_.clear(); + } + } else { + // Warn on the leading edge and on displacement (that one loses a reply); + // the drain's re-refusals of the same reply stay quiet. + if (this->pending_unpairing_.empty()) { + ESP_LOGW(TAG, "Unpair reply for %012" PRIX64 " deferred, TCP buffer full", address); + } else if (!this->pending_unpairing_.matches(address)) { + ESP_LOGW(TAG, "Owed unpair reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, + this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); + } +#endif } // Shared by both platform paths: the neutral bluetooth_device_request() uses it to diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 0b51d61c60..e7c7c3cb20 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -61,11 +61,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; #ifdef BLUETOOTH_CONNECTION_HAS_GATT -/// One owed freed-slot connected=false notification in a single word: the -/// 48-bit address in the low bits, the sign-extending 16-bit reason on top. -/// Every reason that reaches the pool (esp_gatt_status_t, -/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t. -class PendingDisconnect { +/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit +/// error on top. Every error that reaches it fits int16_t. +class PendingReply { public: constexpr void set(uint64_t address, conn_err_t error) { // Mask: the address originates from the client, and a stray high bit @@ -73,7 +71,9 @@ class PendingDisconnect { this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); } constexpr void clear() { this->word_ = 0; } - // Whole-word test: set() is only ever given a live (nonzero) address. + // Whole-word test: only (address 0, error 0) reads back as nothing owed. + // A zero-address failure still latches, which is correct - that reply is + // owed too. Neither backend can unpair address 0 successfully. constexpr bool empty() const { return this->word_ == 0; } // Masked like set(), so a stray high bit cannot defeat the pool lookups. constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } @@ -86,15 +86,15 @@ class PendingDisconnect { }; // Pin the packing at compile time: mask and sign round-trip for every // reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). -constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { - PendingDisconnect p; +constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingReply p; p.set(address, error); return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); } -static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); -static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); -static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); -static_assert(PendingDisconnect{}.empty()); +static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingReply{}.empty()); #endif class BluetoothProxy final : public Component { @@ -148,7 +148,9 @@ class BluetoothProxy final : public Component { /// False only when the API refused the frame, so the reply is still owed. bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); - void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); + /// No default error: the drain rebuilds success as (error == CONN_OK), so a + /// caller that omitted it would have a reported failure resent as a success. + void send_device_unpairing(uint64_t address, bool success, conn_err_t error); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); void bluetooth_scanner_set_mode(bool active); @@ -294,7 +296,11 @@ class BluetoothProxy final : public Component { // Address-keyed pool of owed freed-slot notifications; loop() resends. // Proxy-only state, kept off BluetoothConnection; entries are not tied to // slot indices. - std::array pending_disconnections_{}; + std::array pending_disconnections_{}; + // Owed unpair reply. The bond is already gone when the send is refused, so + // a retry is told the unpair failed when it succeeded. One slot: a second + // refused unpair displaces the first, as happened to both before this. + PendingReply pending_unpairing_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below From 9f28638ee77056850456e505f38904613f03dd0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 19:15:21 -0500 Subject: [PATCH 110/597] [bluetooth_proxy] Reset every owed reply in one place (#18276) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 46 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 6 +++ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 4c4cbf27a2..0d91b541e8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -704,6 +704,31 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT +void BluetoothProxy::reset_owed_replies_() { + this->connections_free_pending_ = false; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() + // re-drives it from the hub, so clearing it there is free. + this->scanner_state_pending_ = false; +#else + // Force a poll-arm mismatch: a frame refused at subscribe time could + // otherwise match the stale detector and never be retried. Inert on + // unsubscribe: loop() returns at the no-subscriber gate before the + // detector runs, and a re-subscribe re-arms this anyway. + this->last_scan_running_ = !this->hub_->scan_running(); +#endif +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + this->pending_unpairing_.clear(); + this->pending_disconnections_.fill({}); + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the next + // session; silence (the client's timeout) arbitrates. + this->connections_[i]->park_service_stream_(); + this->connections_[i]->clear_pending_ack_(); + } +#endif +} + void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (api_connection != this->api_connection_) { if (this->api_connection_ != nullptr) { @@ -720,18 +745,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection } // Stale retry latches belong to the previous subscriber's session; a // re-subscribe by the current one keeps what it is still owed. - this->connections_free_pending_ = false; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT - this->pending_unpairing_.clear(); - for (uint8_t i = 0; i < this->connection_count_; i++) { - // Neither a partial stream's tail nor an owed done belongs to the new - // session; silence (the client's timeout) arbitrates. - this->connections_[i]->park_service_stream_(); - // An ack owed to the previous subscriber means nothing to the new one. - this->connections_[i]->clear_pending_ack_(); - } - this->pending_disconnections_.fill({}); -#endif + this->reset_owed_replies_(); } this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK @@ -748,13 +762,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; - this->connections_free_pending_ = false; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT - this->pending_unpairing_.clear(); -#endif -#ifdef USE_BLE_SCANNER_STATE_CALLBACK - this->scanner_state_pending_ = false; -#endif + this->reset_owed_replies_(); } void BluetoothProxy::send_connections_free() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e7c7c3cb20..16b153625f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -286,6 +286,12 @@ class BluetoothProxy final : public Component { void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif + /// Drop everything the ending session was owed. One list, so a new latch is + /// one edit rather than two call sites where an omission looks deliberate. + /// Drops state only, never sends: api_connection_ is the departing + /// subscriber on subscribe and nullptr on unsubscribe. + void reset_owed_replies_(); + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; From be5e28ea9e09bf223c72527264829d1df1e1d804 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:06:57 -0400 Subject: [PATCH 111/597] [core] Warn when running a different source tree than the one you are in (#18288) Co-authored-by: J. Nick Koston --- esphome/__main__.py | 44 +++++++++++ tests/unit_tests/test_main.py | 142 ++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index cc1e12cb3a..c4ba6b54d7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2509,6 +2509,49 @@ def parse_args(argv): return parser.parse_args(arguments) +def _warn_if_source_tree_mismatch() -> None: + """Warn when the checkout the user is standing in is not the one being run. + + An editable install records one absolute path, so a venv shared between git + worktrees (or reused after a checkout is copied or renamed) keeps importing + the tree it was installed from. Every command then silently runs, and + compiles, sources the user is not looking at. Only fires inside a checkout, + so ordinary installs never see it. + """ + try: + cwd = Path.cwd() + except OSError: + return # working directory is gone; a diagnostic must not break startup + for candidate in (cwd, *cwd.parents): + if (candidate / "esphome" / "__main__.py").is_file(): + standing_in = candidate.resolve() + break + else: + return # not inside a checkout; nothing to compare against + + running = Path(__file__).resolve().parent.parent + # Both sides are resolved, so on a case-sensitive filesystem this matches + # plain equality. samefile() compares device and inode, which additionally + # covers a case-insensitive filesystem (macOS) reaching one directory by + # differently cased paths. Falls back to equality if either path is gone. + try: + same = standing_in.samefile(running) + except OSError: + same = standing_in == running + if same: + return + + _LOGGER.warning( + "Running ESPHome from a different checkout than the one you are in:\n" + " running from: %s\n" + " you are in: %s\n" + "The installed esphome resolves to the first, so its sources are used.\n" + "Run 'python -m esphome' from the second to use that one instead.", + running, + standing_in, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2527,6 +2570,7 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6c13cd5f12..14b49a1a05 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,6 +18,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange +from esphome import __main__ as main from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -6760,3 +6761,144 @@ def test_check_permissions_unreadable_port() -> None: pytest.raises(EsphomeError, match="read or write permission"), ): check_permissions("/dev/ttyUSB99") + + +def _make_checkout(root: Path) -> Path: + """Create a directory that looks like an esphome checkout.""" + (root / "esphome").mkdir(parents=True) + (root / "esphome" / "__main__.py").write_text("", encoding="utf-8") + return root + + +def test_warn_source_tree_mismatch_warns_for_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in a checkout other than the one being run warns.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + assert "main" in caplog.text + + +def test_warn_source_tree_mismatch_silent_in_same_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in the tree that is running is the normal case and is silent.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_outside_checkout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """An ordinary install run from a config directory never warns.""" + running = _make_checkout(tmp_path / "main") + config_dir = tmp_path / "configs" + config_dir.mkdir() + monkeypatch.chdir(config_dir) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_in_subdirectory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A subdirectory of the running tree resolves to that tree, so no warning.""" + tree = _make_checkout(tmp_path / "main") + subdir = tree / "esphome" / "components" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_warns_when_stat_fails_on_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The samefile() fallback must still warn when the trees really differ.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + + +def test_warn_source_tree_mismatch_silent_when_cwd_is_gone( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A deleted working directory must not turn the diagnostic into a traceback.""" + running = _make_checkout(tmp_path / "main") + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_filenotfound() -> Path: + raise FileNotFoundError("cwd is gone") + + monkeypatch.setattr(Path, "cwd", staticmethod(raise_filenotfound)) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_falls_back_when_stat_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """If samefile() cannot stat, fall back to comparing the paths.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + # Same tree, so the path comparison still finds them equal and stays silent + assert not caplog.text From 3f5b8139f32ffd28722f8544a688584d1b820d05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <3060199+jesserockz@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:52:25 -0500 Subject: [PATCH 112/597] [bluetooth_proxy] Latch the connection replies and tighten the send paths (#18278) --- .../bluetooth_connection.cpp | 9 +- .../bluetooth_connection.h | 11 +- .../bluetooth_connection_hub.cpp | 61 ++++- .../bluetooth_connection_hub.h | 28 ++- .../bluetooth_proxy/bluetooth_proxy.cpp | 236 ++++++++---------- .../bluetooth_proxy/bluetooth_proxy.h | 24 +- 6 files changed, 220 insertions(+), 149 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 94bb119c84..a7e9825e56 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -46,12 +46,11 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size #endif // BLUETOOTH_CONNECTION_HAS_GATT -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) namespace esphome::bluetooth_connection { -// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, -// including advertisement-only ones where no GATT backend (and none of the -// gated surface above) is compiled - so this block sits outside that gate. +// Address-scoped Bluedroid maintenance. Gated with the connection surface: +// the advertisement-only arm no longer dispatches these requests at all. conn_err_t unpair_device(uint64_t address) { esp_bd_addr_t bda; @@ -66,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) { } } // namespace esphome::bluetooth_connection -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index d200c6b48f..bcfbdaa6cf 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -20,10 +20,9 @@ // wired by codegen (one slot per connection). This is the single spelling of // that predicate - the hub wrapper and the API request handlers gate on it. // The wrapper serves the proxy's API surface, so it compiles only when a -// backend AND the proxy are present; advertisement-only and backend-only -// builds get the clean-error handlers instead. Address-scoped maintenance -// (unpair, cache clear) still works there through the per-platform free -// functions below. +// backend AND the proxy are present. The address-scoped maintenance functions +// below are only reached from that gated surface; their #else stubs just +// keep this header parsing on arms without a backend. #if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) #define BLUETOOTH_CONNECTION_HAS_GATT #endif @@ -68,12 +67,12 @@ static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) +#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT) conn_err_t unpair_device(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } #endif -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 3909e16305..8707637e9d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -38,7 +38,7 @@ void BluetoothConnection::set_address(uint64_t address) { this->proxy_->update_address_slot_(this->address_, address); // Slot changing hands: anything owed belonged to the old address. The // choke point for every reassignment, not just reset_connection_()'s path. - this->clear_pending_ack_(); + this->clear_owed_flags_(); this->address_ = address; if (address == 0) { this->address_str_[0] = '\0'; @@ -97,8 +97,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->services_discovered_ = false; this->paired_ = false; // Link gone: the slot may hold a different device before the drain runs. - this->clear_pending_ack_(); - this->batch_stalled_ = false; + this->clear_owed_flags_(); this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } @@ -142,7 +141,7 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, param_err); } - this->proxy_->send_device_connection(this->address_, true, mtu); + this->send_connected_reply_(); this->proxy_->send_connections_free(); return; } @@ -180,10 +179,49 @@ void BluetoothConnection::on_service_discovery_done(int error) { this->mtu_); this->state_ = ClientState::ESTABLISHED; this->services_discovered_ = true; - this->proxy_->send_device_connection(this->address_, true, this->mtu_); + this->send_connected_reply_(); this->proxy_->send_connections_free(); } +void BluetoothConnection::flush_owed_replies_() { + // Connected first: the client should never see services-done or an ack for + // a link it has not been told is up. Structural, not size-dependent: a + // still-owed connected reply defers the smaller sends to the next tick. + if (this->connected_reply_owed_) { + this->send_connected_reply_(); + if (this->connected_reply_owed_) { + // The retry limits are wall-clock windows: age the deferred budgets so + // a reply cannot outlive the window it was sized for. + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->age_services_done_(); + } + if (this->has_pending_ack_()) { + this->age_pending_ack_(); + } + return; + } + } + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_services_done_(); + } + if (this->has_pending_ack_()) { + this->flush_pending_ack_(); + } +} + +void BluetoothConnection::send_connected_reply_() { + if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) { + this->connected_reply_owed_ = false; + return; + } + // Warn on the leading edge only, as elsewhere: the drop must be visible but + // must not add traffic to the connection that just refused a frame. + if (!this->connected_reply_owed_) { + ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_); + this->connected_reply_owed_ = true; + } +} + void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, operation, handle, status); @@ -245,13 +283,16 @@ void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t } void BluetoothConnection::flush_pending_ack_() { - // No-op on its own rather than relying on the proxy drain's pre-check. if (!this->has_pending_ack_()) return; if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { this->clear_pending_ack_(); return; } + this->age_pending_ack_(); +} + +void BluetoothConnection::age_pending_ack_() { if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { // Undeliverable: past here the client has given up and may have re-asked, // and a late reply would answer the new request instead of this one. @@ -401,7 +442,13 @@ void BluetoothConnection::send_services_done_() { ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); this->services_done_retries_ = 0; this->send_service_ = SERVICES_DONE_PENDING; - } else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + } else { + this->age_services_done_(); + } +} + +void BluetoothConnection::age_services_done_() { + if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); this->send_service_ = DONE_SENDING_SERVICES; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index b0d0f5fd46..3553f8bf00 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -151,6 +151,20 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// caller rewinds the cursor), and a warning per attempt would add traffic /// to the connection already refusing frames. Both streamers route here. void note_batch_stalled_(); + /// Send the connected=true reply, latching it if the API refuses. Rebuilt + /// from address_ and mtu_, so the latch is one bit; a dropped confirmation + /// leaves the client timing out while this slot holds a live link. No retry + /// bound: the slot's lifetime is the bound (teardown clears the flag). + void send_connected_reply_(); + /// Re-offer everything this slot owes. One entry point so the proxy drain + /// does not have to know which latches exist. + void flush_owed_replies_(); + /// Drop everything this slot owes, in one write to the shared tail byte. + void clear_owed_flags_() { + this->pending_ack_ = PendingAck::PENDING_ACK_NONE; + this->batch_stalled_ = false; + this->connected_reply_owed_ = false; + } /// Sole construction site for these replies, shared by send and retry. bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); /// First attempt: send, and latch it for the drain if the API refuses. @@ -162,6 +176,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { } /// Re-offer the owed reply; clears on success, stays owed on a refusal. void flush_pending_ack_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_pending_ack_(); // A backend providing its own streamer (see the contract doc) builds the // response in place from its stack cache; the rest use the table streamer. // Template so the discarded branch is not odr-checked against backends @@ -177,8 +193,6 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// interrupted stream must never be declared complete (the client's /// timeout arbitrates), and an owed done is dropped with it. void park_service_stream_() { - // Agree with reset_connection_(): a stall flag left set would swallow the - // next session's leading-edge warning. this->batch_stalled_ = false; if (this->send_service_ >= 0) { this->backend_->release_services(); @@ -193,6 +207,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { /// retries). Callers release the table first; the message needs only the /// address. void send_services_done_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_services_done_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -220,8 +236,10 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { // uses tail slack instead of pushing address_ out by 6 bytes of padding. uint16_t pending_ack_handle_{0}; - // Group 5: bit-packed tail. pending_ack_error_ takes the 8-aligned object - // from 48 to 56, so the third tail byte is free; first two stay packed. + // Group 5: bit-packed tail. The first two bytes were already full, so the + // first added bit forced a third and took the 8-aligned object 48 -> 56; + // the handle, error and retry counter ride in that padding. Four bitfield + // bits left; another byte-sized member costs 8 per slot. static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); @@ -238,6 +256,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; /// Set while a refused batch is retrying, so only the first one warns. bool batch_stalled_ : 1 {false}; + /// An owed connected=true reply; the proxy's paced drain re-offers it. + bool connected_reply_owed_ : 1 {false}; // Plain byte after the bitfields: takes the padding byte instead of // straddling pending_ack_'s storage unit and growing the object. static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 0d91b541e8..0cf8483cea 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -122,6 +122,18 @@ void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const } #endif // BLUETOOTH_CONNECTION_HAS_GATT +void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address); +} + void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); } @@ -129,7 +141,10 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); + if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) { + // No connection, so nothing to latch against; the client's timeout arbitrates. + this->log_reply_dropped_("Not-connected", address); + } } void BluetoothProxy::log_advertisement_flush_() { @@ -209,12 +224,12 @@ void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t e } } if (free_entry != nullptr) { + this->log_reply_deferred_("Disconnect", address); free_entry->set(address, error); return; } // Every entry is owed: evict the first so the newest loss is not silent too. - ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full", - (unsigned long long) this->pending_disconnections_[0].address()); + this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address); this->pending_disconnections_[0].set(address, error); } @@ -224,19 +239,39 @@ void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { for (uint8_t i = 0; i < this->connection_count_; i++) { if (this->pending_disconnections_[i].matches(address)) { this->pending_disconnections_[i].clear(); + return; // latch_pending_disconnection_ keeps at most one entry per address } } } -void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { - if (!this->send_device_connection(connection->get_address(), false, 0, reason)) { - // The client has no other way to learn of an unsolicited disconnect; - // latch and let loop()'s paced drain deliver it. V by design: a louder - // level would ride the same congested link this reports on. - ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(), - connection->address_str()); - this->latch_pending_disconnection_(connection->get_address(), reason); +void BluetoothProxy::answer_device_disconnected_(uint64_t address) { + if (this->send_device_connection(address, false)) { + // A landed answer satisfies any owed notification for the address; a + // drained duplicate would follow it otherwise. + this->clear_pending_disconnection_(address); + return; } + // Not latched: the client's own request timeout arbitrates, and pooling + // these would let a request retry loop displace an unsolicited disconnect. + this->log_reply_dropped_("Disconnect", address); +} + +void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) { + if (this->send_device_connection(address, false, 0, error)) { + // A later disconnect landing for an address that still has one owed would + // otherwise have the drain repeat it. + this->clear_pending_disconnection_(address); + return; + } + // A dropped disconnect leaves the client believing the link is live, so + // every GATT operation on it times out until something else corrects it. + // latch_pending_disconnection_() reports the leading edge. + this->latch_pending_disconnection_(address, error); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + // The client has no other way to learn of an unsolicited disconnect. + this->send_device_disconnected_(connection->get_address(), reason); connection->set_address(0); connection->send_service_ = INIT_SENDING_SERVICES; this->send_connections_free(); @@ -282,18 +317,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), connection->address_str()); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); - this->send_device_connection(msg.address, true); + connection->send_connected_reply_(); this->send_connections_free(); return; } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { @@ -320,7 +355,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); return; } @@ -328,7 +363,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->disconnect(); } else { connection->set_address(0); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); } break; @@ -372,7 +407,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { ESP_LOGE(TAG, "V1 connections removed"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); break; } } @@ -484,7 +519,8 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; - // Send results unchecked (esp32 parity): a drop resolves via the client timeout. + // Not latched (esp32 parity): the request is idempotent, so a drop resolves + // via the client timeout and a retry gives the same answer. Still reported. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -495,7 +531,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); resp.error = GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } return; } @@ -506,7 +544,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn static_cast(std::min(msg.max_interval, max_val)), static_cast(std::min(msg.latency, max_val)), static_cast(std::min(msg.timeout, max_val))); - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } } #endif // BLUETOOTH_CONNECTION_HAS_GATT @@ -568,9 +608,9 @@ void BluetoothProxy::loop() { if (this->connections_free_pending_ && this->api_connection_ != nullptr) { // Resend a dropped slot-state update, paced by the 100 ms gate so the - // retry does not hammer the congestion it exists to survive; the - // advertisement-only arm answers DISCONNECT requests with this message - // too, so the drain compiles on every proxy build. + // retry does not hammer the congestion it exists to survive. Every build + // sends this at subscribe time (api_connection.cpp), so the drain + // compiles on every proxy build. this->connections_free_pending_ = false; this->send_connections_free(this->api_connection_); } @@ -593,17 +633,17 @@ void BluetoothProxy::loop() { // Paced retries of owed per-slot notifications; subscriber swaps clear // stale latches before this runs. for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->send_service_ == SERVICES_DONE_PENDING) { - connection->send_services_done_(); - } - if (connection->has_pending_ack_()) { - connection->flush_pending_ack_(); - } + this->connections_[i]->flush_owed_replies_(); + } + // Address-keyed, not slot-keyed, so it gets its own loop; bounded by + // connection_count_ like the latch and clear helpers. Not pre-cleared: + // the sender clears on success and re-latches on refusal, keeping the + // latch's leading-edge warn honest (same shape as the unpair drain). + for (uint8_t i = 0; i < this->connection_count_; i++) { auto &owed = this->pending_disconnections_[i]; - if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { - owed.clear(); - } + if (owed.empty()) + continue; + this->send_device_disconnected_(owed.address(), owed.error()); } // An owed unpair reply. Not pre-cleared: the sender clears on success and @@ -632,75 +672,21 @@ void BluetoothProxy::loop() { #ifndef BLUETOOTH_CONNECTION_HAS_GATT -// Advertisement-only proxy. GATT client connections are excluded at compile -// time (no connection backend on this platform, or active: false), so every -// connection-oriented request is answered with a clean error instead of -// silence, and Home Assistant treats the proxy as passive. +// Advertisement-only proxy: no connection backend on this platform, or +// active: false. get_feature_flags() then omits FEATURE_ACTIVE_CONNECTIONS, +// so a client treats the proxy as passive and never sends a connection or +// GATT request. These exist only because the api layer dispatches them +// unconditionally; answering would link response encoders this build has no +// use for. -void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { - switch (msg.request_type) { - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: - ESP_LOGW(TAG, "Active connections are not supported on this platform"); - this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: - // Not an error: the device is already disconnected, which is the requested state. - this->send_device_connection(msg.address, false); - this->send_connections_free(); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: - this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - // Address-scoped maintenance needs no connection slot: real on esp32 - // (Bluedroid bond table), the stub elsewhere keeps the old error reply. - conn_err_t ret = bluetooth_connection::unpair_device(msg.address); - this->send_device_unpairing(msg.address, ret == CONN_OK, ret); - break; - } - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); - this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); - break; - } - } -} - -void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic"); -} - -void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic"); -} - -void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor"); -} - -void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor"); -} - -void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) { - this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); -} - -void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic"); -} - -void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { - if (this->api_connection_ == nullptr) - return; - // Send results unchecked (esp32 parity): a drop resolves via the client timeout. - api::BluetoothSetConnectionParamsResponse resp; - resp.address = msg.address; - resp.error = GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); -} +void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} +void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} +void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} #endif // !BLUETOOTH_CONNECTION_HAS_GATT @@ -723,8 +709,9 @@ void BluetoothProxy::reset_owed_replies_() { for (uint8_t i = 0; i < this->connection_count_; i++) { // Neither a partial stream's tail nor an owed done belongs to the next // session; silence (the client's timeout) arbitrates. - this->connections_[i]->park_service_stream_(); - this->connections_[i]->clear_pending_ack_(); + auto *connection = this->connections_[i]; + connection->park_service_stream_(); + connection->clear_owed_flags_(); } #endif } @@ -810,6 +797,7 @@ bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err return this->api_connection_->send_message(call); } +#ifdef BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -818,13 +806,16 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err call.paired = paired; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: a retried PAIR is answered from is_paired(), so the client + // recovers on its own. Still worth saying it happened. + this->log_reply_dropped_("Pairing", address); + } } void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT // An owed success is the authoritative answer: a later attempt for the // same address fails only because the first already removed the bond. if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && @@ -832,38 +823,29 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_ success = true; error = CONN_OK; } -#endif api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - // Advertisement-only builds answer this with a canned reply and keep no - // retry state, so only the latch is conditional, not the send. - [[maybe_unused]] bool sent = this->api_connection_->send_message(call); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT - if (sent) { + if (this->api_connection_->send_message(call)) { // A later unpair landing for an address that still has one owed would // otherwise have the drain repeat it. if (this->pending_unpairing_.matches(address)) { this->pending_unpairing_.clear(); } - } else { - // Warn on the leading edge and on displacement (that one loses a reply); - // the drain's re-refusals of the same reply stay quiet. - if (this->pending_unpairing_.empty()) { - ESP_LOGW(TAG, "Unpair reply for %012" PRIX64 " deferred, TCP buffer full", address); - } else if (!this->pending_unpairing_.matches(address)) { - ESP_LOGW(TAG, "Owed unpair reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, - this->pending_unpairing_.address(), address); - } - this->pending_unpairing_.set(address, error); + return; } -#endif + if (this->pending_unpairing_.empty()) { + this->log_reply_deferred_("Unpair", address); + } else if (!this->pending_unpairing_.matches(address)) { + this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); } -// Shared by both platform paths: the neutral bluetooth_device_request() uses it to -// answer a clear-cache request with a clean error, so it must not be esp32-guarded. +// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE, +// so its response encoder would be dead weight there. void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -872,8 +854,12 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con call.success = success; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: clear-cache is idempotent, so a retry gives the same answer. + this->log_reply_dropped_("Clear-cache", address); + } } +#endif BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 16b153625f..de70b35aaf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -138,8 +138,8 @@ class BluetoothProxy final : public Component { } /// False only when a subscriber refused the frame; true = delivered or - /// nobody subscribed. Request-answer callers ignore the result (client - /// timeouts cover those); only reset_connection_slot_ latches for retry. + /// nobody subscribed. Refusals latch in send_device_disconnected_() and + /// send_connected_reply_(); other callers report via log_reply_dropped_(). bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); @@ -147,11 +147,13 @@ class BluetoothProxy final : public Component { bool send_gatt_services_done(uint64_t address); /// False only when the API refused the frame, so the reply is still owed. bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); /// No default error: the drain rebuilds success as (error == CONN_OK), so a /// caller that omitted it would have a reported failure resent as a success. void send_device_unpairing(uint64_t address, bool success, conn_err_t error); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); +#endif void bluetooth_scanner_set_mode(bool active); @@ -230,6 +232,9 @@ class BluetoothProxy final : public Component { void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; + // The one deliberately ignored result: advertisements are perishable and + // this is the highest-frequency send here, so reporting each drop would be + // the flood the batch pacing exists to avoid. this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE this->log_advertisement_flush_(); @@ -282,6 +287,15 @@ class BluetoothProxy final : public Component { void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); /// Drop any owed freed-slot notification for this address (client reconnected). void clear_pending_disconnection_(uint64_t address); + /// Send connected=false and pool it for the paced drain if refused. A + /// dropped disconnect desynchronises the proxy: the client keeps a link it + /// believes is live and every operation on it times out. Unsolicited and + /// drained notifications only; request answers use the variant below. + void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK); + /// Answer a request with connected=false. Never pools: a refusal falls back + /// to the client's request timeout, keeping the pool for the unsolicited + /// notifications the client cannot recover on its own. + void answer_device_disconnected_(uint64_t address); /// Pool a refused freed-slot notification for the paced drain. void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif @@ -291,6 +305,12 @@ class BluetoothProxy final : public Component { /// Drops state only, never sends: api_connection_ is the departing /// subscriber on subscribe and nullptr on unsubscribe. void reset_owed_replies_(); + /// Report a reply we deliberately do not latch, so no drop is silent. + void log_reply_dropped_(const char *what, uint64_t address); + /// A latched reply's leading edge; the drain's re-refusals stay quiet. + void log_reply_deferred_(const char *what, uint64_t address); + /// A latched reply lost to a newer one for a different address. + void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) From 3349046c5d0d4963e20f37e6ac0181f869557315 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:56:05 +1200 Subject: [PATCH 113/597] [rp2] Record the RP2350 die on generated board entries (#18305) --- esphome/components/rp2/boards.jinja2 | 4 + esphome/components/rp2/boards.py | 48 +++++++++++ esphome/components/rp2/generate_boards.py | 60 ++++++++++---- .../components/test_rp2_generate_boards.py | 83 ++++++++++++++++++- 4 files changed, 175 insertions(+), 20 deletions(-) diff --git a/esphome/components/rp2/boards.jinja2 b/esphome/components/rp2/boards.jinja2 index 9223009c26..6e5e55d771 100644 --- a/esphome/components/rp2/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -14,6 +14,10 @@ RP2_BOARD_PINS = { {%- endfor %} } +# RP2350 boards carry a {{ rp2350_die_key | repr }} key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { {%- for name, info in boards %} {{ name | repr }}: { diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index d2502b8fb8..4b2f9769b0 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1533,6 +1533,10 @@ RP2_BOARD_PINS = { }, } +# RP2350 boards carry a 'die' key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { "0xcb_helios": { "name": "0xCB Helios", @@ -1548,6 +1552,7 @@ BOARDS = { "name": "MyMakers RP2350B", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "MyRP_bot": { "name": "MyMakers RP2040", @@ -1588,11 +1593,13 @@ BOARDS = { "name": "Adafruit Feather RP2350 Adalogger", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "adafruit_feather_rp2350_hstx": { "name": "Adafruit Feather RP2350 HSTX", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "adafruit_feather_scorpio": { "name": "Adafruit Feather RP2040 SCORPIO", @@ -1618,6 +1625,7 @@ BOARDS = { "name": "Adafruit Fruit Jam RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_itsybitsy": { "name": "Adafruit ItsyBitsy RP2040", @@ -1643,6 +1651,7 @@ BOARDS = { "name": "Adafruit Metro RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_qtpy": { "name": "Adafruit QT Py RP2040", @@ -1763,16 +1772,19 @@ BOARDS = { "name": "iLabs Challenger 2350 BConnect", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_2350_nbiot": { "name": "iLabs Challenger 2350 NB-IoT", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_2350_wifi6_ble5": { "name": "iLabs Challenger 2350 WiFi/BLE", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_nb_2040_wifi": { "name": "iLabs Challenger NB 2040 WiFi", @@ -1788,6 +1800,7 @@ BOARDS = { "name": "Cytron IRIV IO Controller", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "cytron_maker_nano_rp2040": { "name": "Cytron Maker Nano RP2040", @@ -1808,6 +1821,7 @@ BOARDS = { "name": "Cytron Motion 2350 Pro", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "datanoisetv_picoadk": { "name": "DatanoiseTV PicoADK", @@ -1818,6 +1832,7 @@ BOARDS = { "name": "DatanoiseTV PicoADK v2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "degz_suibo": { "name": "Degz Robotics Suibo RP2040", @@ -1863,6 +1878,7 @@ BOARDS = { "name": "Generic RP2350", "mcu": "rp2350", "max_pin": 47, + "die": None, }, "groundstudio_marble_pico": { "name": "GroundStudio Marble Pico", @@ -1873,6 +1889,7 @@ BOARDS = { "name": "iLabs CPico 2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "ilabs_rpico32": { "name": "iLabs RPICO32", @@ -1888,6 +1905,7 @@ BOARDS = { "name": "Architeuthis Flux Jumperless V5", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "melopero_cookie_rp2040": { "name": "Melopero Cookie RP2040", @@ -1928,16 +1946,19 @@ BOARDS = { "name": "Olimex Pico2BB48", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xl": { "name": "Olimex Pico2XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xxl": { "name": "Olimex Pico2XXL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_rp2040pico30": { "name": "Olimex RP2040-Pico30", @@ -1963,6 +1984,7 @@ BOARDS = { "name": "Pimoroni Explorer", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pga2040": { "name": "Pimoroni PGA2040", @@ -1973,16 +1995,19 @@ BOARDS = { "name": "Pimoroni PGA2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2": { "name": "Pimoroni PicoPlus2", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2w": { "name": "Pimoroni PicoPlus2W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -1995,11 +2020,13 @@ BOARDS = { "name": "Pimoroni Plasma2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "pimoroni_plasma2350w": { "name": "Pimoroni Plasma2350W", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, }, "pimoroni_servo2040": { @@ -2016,6 +2043,7 @@ BOARDS = { "name": "Pimoroni Tiny2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "pintronix_pinmax": { "name": "Pintronix PinMax", @@ -2046,11 +2074,13 @@ BOARDS = { "name": "Raspberry Pi Pico 2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "rpipico2w": { "name": "Raspberry Pi Pico 2W", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2085,6 +2115,7 @@ BOARDS = { "name": "Seeed XIAO RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "silicognition_rp2040_shim": { "name": "Silicognition RP2040-Shim", @@ -2100,6 +2131,7 @@ BOARDS = { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "solderparty_rp2040_stamp": { @@ -2111,21 +2143,25 @@ BOARDS = { "name": "Solder Party RP2350 Stamp", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "solderparty_rp2350_stamp_xl": { "name": "Solder Party RP2350 Stamp XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "sparkfun_iotnode_lorawanrp2350": { "name": "SparkFun IoT Node LoRaWAN", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "sparkfun_iotredboard_rp2350": { "name": "SparkFun IoT RedBoard RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "sparkfun_micromodrp2040": { @@ -2142,6 +2178,7 @@ BOARDS = { "name": "SparkFun ProMicro RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "sparkfun_thingplusrp2040": { "name": "SparkFun Thing Plus RP2040", @@ -2152,6 +2189,7 @@ BOARDS = { "name": "SparkFun Thing Plus RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2159,6 +2197,7 @@ BOARDS = { "name": "SparkFun XRP Controller", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -2233,32 +2272,38 @@ BOARDS = { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350_pizero": { "name": "Waveshare RP2350 PiZero", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "waveshare_rp2350_plus": { "name": "Waveshare RP2350 Plus", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350b_plus_w": { "name": "Waveshare RP2350B Plus W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "weact_rp2350b": { "name": "WeAct Studio RP2350B Core Board", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "wiznet_5100s_evb_pico": { "name": "WIZnet W5100S-EVB-Pico", @@ -2269,6 +2314,7 @@ BOARDS = { "name": "WIZnet W5100S-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_5500_evb_pico": { "name": "WIZnet W5500-EVB-Pico", @@ -2279,6 +2325,7 @@ BOARDS = { "name": "WIZnet W5500-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_55rp20_evb_pico": { "name": "WIZnet W55RP20-EVB-Pico", @@ -2294,6 +2341,7 @@ BOARDS = { "name": "WIZnet W6300-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_wizfi360_evb_pico": { "name": "WIZnet WizFi360-EVB-Pico", diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index 5618287cce..cd3f50182c 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -37,13 +37,23 @@ MCU_MAX_PIN = { "rp2350": 47, # GPIO 0-47 (RP2350B; A-die boards are narrowed to 29 below) } DEFAULT_MAX_PIN = 29 -# The RP2350 comes in two die variants: RP2350A exposes GPIO 0-29, RP2350B -# GPIO 0-47. Variant headers declare the die via PICO_RP2350A (1 = A, 0 = B). +# The RP2350 currently comes in two die variants: RP2350A exposes GPIO 0-29, +# RP2350B GPIO 0-47. Variant headers declare the die via PICO_RP2350A +# (1 = A, 0 = B). +RP2350_DIE_A = "A" +RP2350_DIE_B = "B" RP2350A_MAX_PIN = 29 +# Key recording the die letter on RP2350 board entries. Holds a letter rather +# than a bool so a future die can be named instead of forced into "not A". +RP2350_DIE_KEY = "die" PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)") # Accepts the literal forms seen in these headers: 1, (1), 1u, (1u) RP2350A_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350A\s+(\S+)") +# Only PICO_RP2350A exists today. A define for any other die letter means the +# A/B assumption below no longer holds. The trailing \b keeps this from +# matching unrelated names such as PICO_RP2350_A2_SUPPORTED. +OTHER_DIE_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350(?!A\b)([B-Z])\b") RP2350A_MENU_PLACEHOLDER = "__PICO_RP2350A" @@ -62,23 +72,30 @@ def parse_variant_pins(variant_dir: Path) -> dict[str, int]: return pins -def parse_variant_is_rp2350a(variant_dir: Path) -> bool: - """Return True if the variant declares an RP2350A die (GPIO 0-29 only). +def parse_variant_rp2350_die(variant_dir: Path) -> str | None: + """Return the RP2350 die letter the variant declares, or None if unknown. Generic boards leave the die a build-time menu choice (PICO_RP2350A is set - to a __PICO_RP2350A placeholder rather than a literal); those return False - so they keep the permissive B-die pin range. + to a __PICO_RP2350A placeholder rather than a literal); those return None, + meaning the die is genuinely unknown at code generation time. They keep the + permissive B-die pin range, but that is a fallback and must not be recorded + as a known die. A missing or unrecognized define raises: silently treating it as B-die would widen pin validation back to GPIO 47 on A-die boards, so a framework - bump that changes the header format must fail loudly here instead. + bump that changes the header format must fail loudly here instead. The same + goes for a die beyond A and B: PICO_RP2350A is a yes/no answer about the A + die, so "not A" can only be read as B while A and B are the whole family. """ header = variant_dir / "pins_arduino.h" - match = ( - RP2350A_DEFINE_RE.search(header.read_text(encoding="utf-8")) - if header.exists() - else None - ) + text = header.read_text(encoding="utf-8") if header.exists() else "" + if other_die := OTHER_DIE_DEFINE_RE.search(text): + raise ValueError( + f"{header}: found a PICO_RP2350{other_die.group(1)} define; the " + "RP2350 gained a die beyond A and B, so PICO_RP2350A being 0 no " + "longer means the B die" + ) + match = RP2350A_DEFINE_RE.search(text) if match is None: raise ValueError( f"{header}: no PICO_RP2350A define found; cannot classify the " @@ -86,14 +103,14 @@ def parse_variant_is_rp2350a(variant_dir: Path) -> bool: ) value = match.group(1) if value == RP2350A_MENU_PLACEHOLDER: - return False + return None literal = value.strip("()u") if not literal.isdigit(): raise ValueError( f"{header}: unrecognized PICO_RP2350A value {value!r}; cannot " "classify the RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" ) - return int(literal) == 1 + return RP2350_DIE_A if int(literal) == 1 else RP2350_DIE_B def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: @@ -104,7 +121,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: board_pins = {} boards = {} variant_pins_cache: dict[str, dict[str, int]] = {} - variant_rp2350a_cache: dict[str, bool] = {} + variant_die_cache: dict[str, str | None] = {} for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem @@ -123,12 +140,14 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: has_wifi = "PICO_CYW43_SUPPORTED=1" in extra_flags max_pin = MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN) + die: str | None = None if mcu == "rp2350": - if variant not in variant_rp2350a_cache: - variant_rp2350a_cache[variant] = parse_variant_is_rp2350a( + if variant not in variant_die_cache: + variant_die_cache[variant] = parse_variant_rp2350_die( variants_dir / variant ) - if variant_rp2350a_cache[variant]: + die = variant_die_cache[variant] + if die == RP2350_DIE_A: max_pin = RP2350A_MAX_PIN board_entry: dict = { @@ -136,6 +155,10 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: "mcu": mcu, "max_pin": max_pin, } + if mcu == "rp2350": + # Recorded explicitly because max_pin cannot express the die: + # 29 also means RP2040, and 47 also means "die not known yet". + board_entry[RP2350_DIE_KEY] = die if has_wifi: board_entry["wifi"] = True boards[board_name] = board_entry @@ -218,6 +241,7 @@ def generate(arduino_pico_path: Path) -> str: cyw43_gpio_offset=CYW43_GPIO_OFFSET, cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, default_max_pin=DEFAULT_MAX_PIN, + rp2350_die_key=RP2350_DIE_KEY, board_pins=sorted(board_pins.items()), boards=sorted(boards.items()), ) diff --git a/tests/unit_tests/components/test_rp2_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py index c5d2214695..329248488c 100644 --- a/tests/unit_tests/components/test_rp2_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -8,7 +8,11 @@ import textwrap import pytest -from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import ( + generate, + load_boards, + parse_variant_pins, +) PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once @@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None: assert boards["rpipico"]["name"] == "Raspberry Pi Pico" assert boards["rpipico"]["mcu"] == "rp2040" assert boards["rpipico"]["max_pin"] == 29 + # The die key only applies to the RP2350, which ships as more than one die + assert "die" not in boards["rpipico"] assert "rpipico" in board_pins assert board_pins["rpipico"]["LED"] == 25 @@ -172,6 +178,7 @@ def test_load_rp2350_board(arduino_pico: Path) -> None: assert boards["rpipico2"]["mcu"] == "rp2350" assert boards["rpipico2"]["max_pin"] == 29 + assert boards["rpipico2"]["die"] == "A" def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None: @@ -200,6 +207,35 @@ def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None: load_boards(arduino_pico) +def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None: + """A third die breaks the "not A means B" reading, so stop rather than guess.""" + _add_board( + arduino_pico, + "future_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n" + + PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="found a PICO_RP2350C define"): + load_boards(arduino_pico) + + +def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None: + """PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter.""" + _add_board( + arduino_pico, + "revision_define", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n" + + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["revision_define"]["die"] == "A" + + def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: """Literal forms like (1u) classify the same as bare 1.""" _add_board( @@ -212,6 +248,7 @@ def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: _, boards = load_boards(arduino_pico) assert boards["paren_die"]["max_pin"] == 29 + assert boards["paren_die"]["die"] == "A" def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: @@ -229,10 +266,15 @@ def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: _, boards = load_boards(arduino_pico) assert boards["weact_rp2350b"]["max_pin"] == 47 + assert boards["weact_rp2350b"]["die"] == "B" def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None: - """Generic boards leave the die a build-time choice; stay permissive.""" + """Generic boards leave the die a build-time choice; stay permissive. + + The permissive range is a fallback, so the die must be recorded as unknown + rather than as the B die. + """ _add_board( arduino_pico, "generic_rp2350", @@ -243,6 +285,43 @@ def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None _, boards = load_boards(arduino_pico) assert boards["generic_rp2350"]["max_pin"] == 47 + assert boards["generic_rp2350"]["die"] is None + + +def test_generated_output_records_die(arduino_pico: Path) -> None: + """The rendered boards.py carries the die on every RP2350 entry.""" + _add_board( + arduino_pico, + "rpipico", + pins_header=PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "a_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "b_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "menu_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER, + ) + + namespace: dict = {} + exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace) + + boards = namespace["BOARDS"] + assert boards["a_die"]["die"] == "A" + assert boards["b_die"]["die"] == "B" + assert boards["menu_die"]["die"] is None + assert "die" not in boards["rpipico"] def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None: From bab62b345bb16cb273a28fbe7334f37ae340e5a3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:19:59 +1200 Subject: [PATCH 114/597] [adc] Fix internal temperature channel on RP2350A under arduino-pico (#18307) --- esphome/components/adc/adc_sensor_rp2.cpp | 25 ++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 2732f5328b..8652a46029 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -19,6 +19,25 @@ namespace esphome::adc { static const char *const TAG = "adc.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif + void ADCSensor::setup() { static bool initialized = false; if (!initialized) { @@ -52,11 +71,7 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - // The on-die temperature sensor sits on the last ADC channel, and which one - // that is depends on the chip: input 4 on RP2040 and RP2350A, but input 8 on - // RP2350B, which has eight external channels instead of four. The SDK - // resolves it for the target being built, so do not hardcode it. - adc_select_input(ADC_TEMPERATURE_CHANNEL_NUM); + adc_select_input(TEMPERATURE_ADC_INPUT); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); From 9605b34c697191f53f6dd47842052f0e642e47b8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:20:41 +1200 Subject: [PATCH 115/597] [adc] Deprecate pin: TEMPERATURE in favour of internal_temperature (#18304) --- esphome/components/adc/__init__.py | 1 + esphome/components/adc/sensor.py | 8 +++++ tests/component_tests/adc/test_adc_sensor.py | 32 +++++++++++++++++++ .../component_tests/adc/test_adc_sensor.yaml | 16 ++++++++++ tests/components/adc/validate.rp2040-ard.yaml | 7 ++++ 5 files changed, 64 insertions(+) create mode 100644 tests/component_tests/adc/test_adc_sensor.py create mode 100644 tests/component_tests/adc/test_adc_sensor.yaml create mode 100644 tests/components/adc/validate.rp2040-ard.yaml diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 555d511f6e..1c50b6b81b 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -231,6 +231,7 @@ def validate_adc_pin(value): return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") + # Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0 if str(value).upper() == "TEMPERATURE": return cv.only_on_rp2("TEMPERATURE") diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index c5a4288c07..b2a4382a21 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -67,6 +67,13 @@ def validate_config(config): # Alter value here so `config` command prints the recommended change config[CONF_ATTENUATION] = _attenuation("12db") + # Remove before 2027.2.0 + if config[CONF_PIN] == "TEMPERATURE": + _LOGGER.warning( + "[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` " + "sensor platform instead. Will be removed in 2027.2.0" + ) + return config @@ -133,6 +140,7 @@ async def to_code(config): if config[CONF_PIN] == "VCC": cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": + # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py new file mode 100644 index 0000000000..a6d86f0305 --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the ADC sensor component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_adc_temperature_pin_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`pin: TEMPERATURE` still works, but warns and points at internal_temperature.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_temperature->set_is_temperature();" in main_cpp + assert "`pin: TEMPERATURE` is deprecated" in caplog.text + assert "internal_temperature" in caplog.text + assert "2027.2.0" in caplog.text + + +def test_adc_regular_pin_is_not_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A normal ADC pin does not emit the temperature deprecation warning.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_voltage->set_is_temperature();" not in main_cpp + assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 diff --git a/tests/component_tests/adc/test_adc_sensor.yaml b/tests/component_tests/adc/test_adc_sensor.yaml new file mode 100644 index 0000000000..9455fef21b --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +rp2: + board: rpipicow + +sensor: + - platform: adc + pin: TEMPERATURE + name: Deprecated ADC Temperature + id: adc_temperature + + - platform: adc + pin: 26 + name: ADC Voltage + id: adc_voltage diff --git a/tests/components/adc/validate.rp2040-ard.yaml b/tests/components/adc/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..cbe15f2746 --- /dev/null +++ b/tests/components/adc/validate.rp2040-ard.yaml @@ -0,0 +1,7 @@ +# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform. +# Remove before 2027.2.0 +sensor: + - id: adc_temperature_sensor + platform: adc + pin: TEMPERATURE + name: ADC Test temperature From 37eae9b466350f4be08056e8a99d89fa5286c417 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 21:31:13 -0500 Subject: [PATCH 116/597] [api] Gate the bluetooth connection messages on their own define (#18281) --- esphome/components/api/api.proto | 44 ++++----- esphome/components/api/api_connection.cpp | 9 +- esphome/components/api/api_connection.h | 6 +- esphome/components/api/api_pb2.cpp | 6 +- esphome/components/api/api_pb2.h | 10 +- esphome/components/api/api_pb2_defines.h | 2 +- esphome/components/api/api_pb2_dump.cpp | 10 +- esphome/components/api/api_pb2_service.cpp | 18 ++-- esphome/components/api/api_pb2_service.h | 18 ++-- .../bluetooth_connection.cpp | 4 +- .../bluetooth_connection.h | 22 ++--- .../bluetooth_connection_bluedroid.cpp | 6 +- .../bluetooth_connection_bluedroid.h | 4 +- .../bluetooth_connection_hub.cpp | 4 +- .../bluetooth_connection_hub.h | 4 +- .../components/bluetooth_proxy/__init__.py | 5 + .../bluetooth_proxy/bluetooth_proxy.cpp | 57 ++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 32 ++++--- esphome/core/defines.h | 2 + script/api_protobuf/api_protobuf.py | 5 +- .../bluetooth_connection/__init__.py | 5 +- .../api/test_api_protobuf_generator.py | 93 +++++++++++++++++++ 22 files changed, 238 insertions(+), 128 deletions(-) create mode 100644 tests/unit_tests/components/api/test_api_protobuf_generator.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 88af5957e7..f1bc9b003a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1760,7 +1760,7 @@ enum BluetoothDeviceRequestType { message BluetoothDeviceRequest { option (id) = 68; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; BluetoothDeviceRequestType request_type = 2; @@ -1771,7 +1771,7 @@ message BluetoothDeviceRequest { message BluetoothDeviceConnectionResponse { option (id) = 69; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool connected = 2; @@ -1782,7 +1782,7 @@ message BluetoothDeviceConnectionResponse { message BluetoothGATTGetServicesRequest { option (id) = 70; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1826,7 +1826,7 @@ message BluetoothGATTService { message BluetoothGATTGetServicesResponse { option (id) = 71; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; repeated BluetoothGATTService services = 2; @@ -1835,7 +1835,7 @@ message BluetoothGATTGetServicesResponse { message BluetoothGATTGetServicesDoneResponse { option (id) = 72; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1843,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse { message BluetoothGATTReadRequest { option (id) = 73; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1852,7 +1852,7 @@ message BluetoothGATTReadRequest { message BluetoothGATTReadResponse { option (id) = 74; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1864,7 +1864,7 @@ message BluetoothGATTReadResponse { message BluetoothGATTWriteRequest { option (id) = 75; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1876,7 +1876,7 @@ message BluetoothGATTWriteRequest { message BluetoothGATTReadDescriptorRequest { option (id) = 76; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1885,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest { message BluetoothGATTWriteDescriptorRequest { option (id) = 77; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1896,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest { message BluetoothGATTNotifyRequest { option (id) = 78; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1906,7 +1906,7 @@ message BluetoothGATTNotifyRequest { message BluetoothGATTNotifyDataResponse { option (id) = 79; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1917,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse { message SubscribeBluetoothConnectionsFreeRequest { option (id) = 80; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; } message BluetoothConnectionsFreeResponse { option (id) = 81; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint32 free = 1; uint32 limit = 2; @@ -1936,7 +1936,7 @@ message BluetoothConnectionsFreeResponse { message BluetoothGATTErrorResponse { option (id) = 82; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1946,7 +1946,7 @@ message BluetoothGATTErrorResponse { message BluetoothGATTWriteResponse { option (id) = 83; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1955,7 +1955,7 @@ message BluetoothGATTWriteResponse { message BluetoothGATTNotifyResponse { option (id) = 84; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1964,7 +1964,7 @@ message BluetoothGATTNotifyResponse { message BluetoothDevicePairingResponse { option (id) = 85; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool paired = 2; @@ -1974,7 +1974,7 @@ message BluetoothDevicePairingResponse { message BluetoothDeviceUnpairingResponse { option (id) = 86; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -1990,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest { message BluetoothDeviceClearCacheResponse { option (id) = 88; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -2807,7 +2807,7 @@ message SerialProxyRequestResponse { message BluetoothSetConnectionParamsRequest { option (id) = 145; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 min_interval = 2; // units of 1.25ms @@ -2819,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest { message BluetoothSetConnectionParamsResponse { option (id) = 146; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; int32 error = 2; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 19d2b14a32..afd7e696af 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1236,6 +1236,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request( void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); } @@ -1269,13 +1270,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() { } } +void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { + bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); +} +#endif + void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); } -void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { - bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); -} #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9ca1b8b6a4..d548b921b3 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -183,6 +183,7 @@ class APIConnection final : public APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg); void on_unsubscribe_bluetooth_le_advertisements_request(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &msg); void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg); void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg); @@ -191,8 +192,9 @@ class APIConnection final : public APIServerConnectionBase { void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg); void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg); void on_subscribe_bluetooth_connections_free_request(); - void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg); +#endif + void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); #endif #ifdef USE_HOMEASSISTANT_TIME @@ -390,7 +392,7 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool send_subscribe_bluetooth_connections_free_response_(); #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5776ec5c62..1b8c6b05bd 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2482,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: @@ -2858,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); @@ -4221,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { return size; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f35f551060..8335dae1f2 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1, }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0, BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1, @@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5, BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6, }; +#endif +#ifdef USE_BLUETOOTH_PROXY enum BluetoothScannerState : uint32_t { BLUETOOTH_SCANNER_STATE_IDLE = 0, BLUETOOTH_SCANNER_STATE_STARTING = 1, @@ -1999,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 68; @@ -2384,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; @@ -3358,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 145; diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 8ebd60fb5d..3603fac6d7 100644 --- a/esphome/components/api/api_pb2_defines.h +++ b/esphome/components/api/api_pb2_defines.h @@ -3,7 +3,7 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_BLUETOOTH_PROXY +#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS) #ifndef USE_API_VARINT64 #define USE_API_VARINT64 #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 17ce7fba45..4d5829e45d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string(enu } } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS template<> const char *proto_enum_to_string(enums::BluetoothDeviceRequestType value) { switch (value) { @@ -606,6 +606,8 @@ const char *proto_enum_to_string(enums::Bluet return ESPHOME_PSTR("UNKNOWN"); } } +#endif +#ifdef USE_BLUETOOTH_PROXY template<> const char *proto_enum_to_string(enums::BluetoothScannerState value) { switch (value) { case enums::BLUETOOTH_SCANNER_STATE_IDLE: @@ -2002,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const } return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); @@ -2173,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error"), this->error); return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse")); dump_field(out, ESPHOME_PSTR("state"), static_cast(this->state)); @@ -2764,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 19dcbfb77c..65c7b8858c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothDeviceRequest::MESSAGE_TYPE: { BluetoothDeviceRequest msg; msg.decode(msg_data, msg_size); @@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: { BluetoothGATTGetServicesRequest msg; msg.decode(msg_data, msg_size); @@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadRequest::MESSAGE_TYPE: { BluetoothGATTReadRequest msg; msg.decode(msg_data, msg_size); @@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteRequest::MESSAGE_TYPE: { BluetoothGATTWriteRequest msg; msg.decode(msg_data, msg_size); @@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTReadDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTWriteDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTNotifyRequest::MESSAGE_TYPE: { BluetoothGATTNotifyRequest msg; msg.decode(msg_data, msg_size); @@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); @@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: { BluetoothSetConnectionParamsRequest msg; msg.decode(msg_data, msg_size); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 5ed78b3385..6abdf7093e 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -115,32 +115,32 @@ class APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_subscribe_bluetooth_connections_free_request(){}; #endif @@ -235,7 +235,7 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif }; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index a7e9825e56..a001729083 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -5,7 +5,7 @@ #include #endif -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/api/api_pb2.h" #include "esphome/core/log.h" @@ -44,7 +44,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) namespace esphome::bluetooth_connection { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index bcfbdaa6cf..b21d997b4f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,16 +16,14 @@ #include #endif -// The connection-aware API request handlers are compiled: a GATT backend is -// wired by codegen (one slot per connection). This is the single spelling of -// that predicate - the hub wrapper and the API request handlers gate on it. -// The wrapper serves the proxy's API surface, so it compiles only when a -// backend AND the proxy are present. The address-scoped maintenance functions -// below are only reached from that gated surface; their #else stubs just -// keep this header parsing on arms without a backend. -#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) -#define BLUETOOTH_CONNECTION_HAS_GATT -#endif +// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has +// proxy connection slots": codegen emits it per configured slot, and each +// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not +// the converse: a backend can exist without proxy slots). The hub +// wrapper, the proxy's connection surface and the API's connection messages +// all gate on it. The address-scoped maintenance functions below are only +// reached from that gated surface; the #else stubs just keep this header +// parsing on arms without a backend. namespace esphome::api { class BluetoothGATTGetServicesResponse; @@ -154,7 +152,7 @@ inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Result of close_service_batch: keep filling the batch or send it now. /// An oversized service is packed alone; a failed (backpressured) send is /// retried from the batch start, so no service is silently skipped. @@ -166,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND }; /// cannot drift. BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 99a6a312ec..076c77b18e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -4,7 +4,7 @@ // The in-place streamer serves the proxy's service-discovery API; backend-only // builds compile without the proxy headers or the streamer. -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "bluetooth_connection.h" #include "bluetooth_connection_hub.h" @@ -391,7 +391,7 @@ void BluedroidGattClient::deliver_pending_search_() { this->listener_->on_service_discovery_done(this->search_status_); } -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // The wrapper's compile-time streamer detection must keep finding this // method; a signature drift would silently fall back to the table streamer, // which proxy builds compile without a materializer. @@ -535,7 +535,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { } conn.batch_stalled_ = false; } -#endif // USE_BLUETOOTH_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS // ---- events ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 19b89ea5cd..0d0b4fed5b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -20,7 +20,7 @@ namespace esphome::bluetooth_connection { -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothConnection; #endif @@ -79,7 +79,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public ble_device_base::GattServiceTable get_service_table() { return {}; } void release_services(); -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// In-place service streamer (the proxy wrapper detects and prefers it): /// builds one api response batch directly from Bluedroid's cached database, /// so the streaming peak is the response itself - the old esp32 model. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 8707637e9d..50267e7c73 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -21,7 +21,7 @@ // request; timeouts, disconnects and errors raise instead of caching. #include "bluetooth_connection_hub.h" -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -560,4 +560,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 3553f8bf00..f87d545f7d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -10,7 +10,7 @@ // The wrapper exists to serve the proxy's API surface; direct consumers // drive the backend themselves, so backend-only builds compile this header // empty. -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" @@ -271,4 +271,4 @@ static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index cc7aed6be2..95f71fc8ea 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -208,6 +208,11 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: # this define whenever a proxy is present (zero on advertisement-only # hubs); sized here so it can never diverge from the loop below. cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + if connections: + # Gates the connection and GATT half of the API surface. A proxy + # without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never + # sends those requests and their handlers and encoders are dead. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") for connection_conf in connections: backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 0cf8483cea..75830e83ef 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -70,9 +70,10 @@ void BluetoothProxy::send_polled_scanner_state_() { #endif // USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { - // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +#endif // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); @@ -111,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), connection->address_str(), ble_device_base::client_state_to_string(state)); @@ -120,8 +121,9 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); } @@ -146,6 +148,7 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl this->log_reply_dropped_("Not-connected", address); } } +#endif void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); @@ -159,7 +162,7 @@ void BluetoothProxy::dump_config() { this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" @@ -177,12 +180,9 @@ void BluetoothProxy::dump_config() { #endif } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. -void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { -// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 +void BluetoothProxy::register_connection(BluetoothConnection *connection) { if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { // Cannot happen with codegen-sized registration; a silent drop would // surface later as a null proxy_ dereference, so refuse loudly. @@ -193,7 +193,6 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c connection->connection_index_ = this->connection_count_; this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; -#endif } void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } @@ -549,7 +548,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn } } -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #ifdef USE_ESP32 @@ -592,7 +591,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #endif // USE_ESP32 void BluetoothProxy::loop() { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Stream pending service-discovery batches every iteration; the streamer // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -606,6 +605,7 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS if (this->connections_free_pending_ && this->api_connection_ != nullptr) { // Resend a dropped slot-state update, paced by the 100 ms gate so the // retry does not hammer the congestion it exists to survive. Every build @@ -614,9 +614,10 @@ void BluetoothProxy::loop() { this->connections_free_pending_ = false; this->send_connections_free(this->api_connection_); } +#endif if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // The API subscriber is gone: tear down any connections it left behind // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -629,7 +630,7 @@ void BluetoothProxy::loop() { return; } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Paced retries of owed per-slot notifications; subscriber swaps clear // stale latches before this runs. for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -670,28 +671,10 @@ void BluetoothProxy::loop() { this->flush_pending_advertisements_(); } -#ifndef BLUETOOTH_CONNECTION_HAS_GATT - -// Advertisement-only proxy: no connection backend on this platform, or -// active: false. get_feature_flags() then omits FEATURE_ACTIVE_CONNECTIONS, -// so a client treats the proxy as passive and never sends a connection or -// GATT request. These exist only because the api layer dispatches them -// unconditionally; answering would link response encoders this build has no -// use for. - -void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} -void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} -void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} - -#endif // !BLUETOOTH_CONNECTION_HAS_GATT - void BluetoothProxy::reset_owed_replies_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->connections_free_pending_ = false; +#endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() // re-drives it from the hub, so clearing it there is free. @@ -703,7 +686,7 @@ void BluetoothProxy::reset_owed_replies_() { // detector runs, and a re-subscribe re-arms this anyway. this->last_scan_running_ = !this->hub_->scan_running(); #endif -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->pending_unpairing_.clear(); this->pending_disconnections_.fill({}); for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -752,6 +735,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti this->reset_owed_replies_(); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -797,7 +781,6 @@ bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err return this->api_connection_->send_message(call); } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -859,7 +842,7 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con this->log_reply_dropped_("Clear-cache", address); } } -#endif +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index de70b35aaf..e5f3a259a1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -29,7 +29,7 @@ using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES; using bluetooth_connection::SERVICES_DONE_PENDING; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS using BluetoothConnection = bluetooth_connection::BluetoothConnection; using ClientState = ble_device_base::ClientState; #endif @@ -60,7 +60,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// One owed address-keyed reply in a single word: 48-bit address low, 16-bit /// error on top. Every error that reaches it fits int16_t. class PendingReply { @@ -98,7 +98,7 @@ static_assert(PendingReply{}.empty()); #endif class BluetoothProxy final : public Component { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; #endif @@ -109,9 +109,9 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void register_connection(BluetoothConnection *connection); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #ifndef USE_ESP32 // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and @@ -120,6 +120,7 @@ class BluetoothProxy final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } #endif // !USE_ESP32 +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg); @@ -128,6 +129,7 @@ class BluetoothProxy final : public Component { void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg); +#endif void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); @@ -137,6 +139,7 @@ class BluetoothProxy final : public Component { return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// False only when a subscriber refused the frame; true = delivered or /// nobody subscribed. Refusals latch in send_device_disconnected_() and /// send_connected_reply_(); other callers report via log_reply_dropped_(). @@ -147,7 +150,6 @@ class BluetoothProxy final : public Component { bool send_gatt_services_done(uint64_t address); /// False only when the API refused the frame, so the reply is still owed. bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); /// No default error: the drain rebuilds success as (error == CONN_OK), so a /// caller that omitted it would have a reported failure resent as a success. @@ -243,22 +245,17 @@ class BluetoothProxy final : public Component { } void log_advertisement_flush_(); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); -#endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT /// Keep the pre-allocated connections-free message in step when a /// connection slot changes address (0 = free). Called from the connection /// classes' set_address(). - // maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the - // body is removed, and the free < MAX compare would trip -Wtype-limits. - void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) { -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + void update_address_slot_(uint64_t old_address, uint64_t new_address) { auto &resp = this->connections_free_response_; if (new_address == 0 && old_address != 0) { if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { @@ -275,7 +272,6 @@ class BluetoothProxy final : public Component { } this->replace_allocated_slot_(0, new_address); } -#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 } void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); void log_slot_accounting_mismatch_(); @@ -305,18 +301,20 @@ class BluetoothProxy final : public Component { /// Drops state only, never sends: api_connection_ is the departing /// subscriber on subscribe and nullptr on unsubscribe. void reset_owed_replies_(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Report a reply we deliberately do not latch, so no drop is silent. void log_reply_dropped_(const char *what, uint64_t address); /// A latched reply's leading edge; the drain's re-refusals stay quiet. void log_reply_deferred_(const char *what, uint64_t address); /// A latched reply lost to a newer one for a different address. void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); +#endif // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Group 2: Fixed-size array of connection pointers std::array connections_{}; // Address-keyed pool of owed freed-slot notifications; loop() resends. @@ -336,16 +334,20 @@ class BluetoothProxy final : public Component { // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; +#endif // Group 4: 1-byte types grouped together bool active_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // A dropped send (full TCP buffer) would leave the API client with a stale // slot state forever; the cached response is current by construction, so // retrying it from loop() is an idempotent resync. bool connections_free_pending_{false}; uint8_t connection_count_{0}; +#endif bool configured_scan_active_{false}; // Configured scan mode from YAML #ifdef USE_BLE_SCANNER_STATE_CALLBACK // A dropped push (full TX buffer) is re-queried from the hub and resent diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 21cea31749..26b9025d96 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -267,8 +267,10 @@ #ifdef USE_ESP32 #define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS #elif defined(USE_RP2) #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 451cd9ac1f..f4eff4a254 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2401,7 +2401,10 @@ def get_varint64_ifdef( # At least one 64-bit varint field is unconditional, so the guard must be unconditional. return True, None ifdefs.discard(None) - return True, ifdefs.pop() if len(ifdefs) == 1 else None + # Several guards: the define is needed under any of them, so emit the union. + # Falling back to unconditional would pull 64-bit varint support into builds + # that have none of them. + return True, " || ".join(sorted(ifdefs)) def build_enum_type(desc, enum_ifdef_map) -> tuple[str, str, str]: diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index eb6e174c0c..9c1ad4e74d 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -3,7 +3,7 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; + # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): # These defines are global to the merged host test binary; safe @@ -11,6 +11,9 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") cg.add_define("USE_BLUETOOTH_PROXY") + # Gates the connection half of the API surface, which is what + # close_service_batch and the GATT response types live behind. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py new file mode 100644 index 0000000000..2a07cbd49c --- /dev/null +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -0,0 +1,93 @@ +"""Unit tests for script/api_protobuf/api_protobuf.py generator logic. + +ci-api-proto.yml only checks that the committed output matches what the +generator currently produces, so a semantic regression in the generator would +be committed and matched without anything failing. These tests pin the +semantics directly. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) + +from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402 +from google.protobuf import descriptor_pb2 # noqa: E402 + + +def _file_with_messages( + *messages: tuple[str, int, bool], +) -> descriptor_pb2.FileDescriptorProto: + """Build a FileDescriptorProto with one single-field message per entry. + + Each entry is (message_name, field_type, deprecated). + """ + file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto") + for name, field_type, deprecated in messages: + msg = file_desc.message_type.add(name=name) + field = msg.field.add(name="value", number=1, type=field_type) + field.options.deprecated = deprecated + return file_desc + + +UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 +INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 +SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 +UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 +FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64 + + +def test_no_varint64_fields() -> None: + file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False)) + assert get_varint64_ifdef(file_desc, {}) == (False, None) + + +@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64]) +def test_single_guard_is_kept(field_type: int) -> None: + file_desc = _file_with_messages(("A", field_type, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X") + + +def test_two_guards_emit_the_union() -> None: + # The regression this pins: multiple guards used to collapse to + # unconditional, pulling 64-bit varint support into unrelated builds. + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + guards = {"A": "USE_X", "B": "USE_Y"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_union_is_sorted_for_deterministic_output() -> None: + file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False)) + guards = {"B": "USE_Y", "A": "USE_X"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_any_unconditional_message_wins() -> None: + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None) + + +def test_deprecated_fields_and_messages_are_ignored() -> None: + file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False)) + file_desc.message_type[1].options.deprecated = True + assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None) + + +def test_make_ifdef_line_simple_identifier() -> None: + assert _make_ifdef_line("USE_X") == "#ifdef USE_X" + + +def test_make_ifdef_line_union_wraps_each_identifier() -> None: + # The second half of the varint64 union guard: compound conditions must + # become #if defined(A) || defined(B), never #ifdef of the raw string. + assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)" + + +def test_make_ifdef_line_conjunction_and_negation() -> None: + assert ( + _make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)" + ) From a2feff8f68c530f3d82ed9e799f0046d4b96e3bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 22:23:08 -0500 Subject: [PATCH 117/597] [api] Mark send_message nodiscard so refused frames are never silent (#18293) --- esphome/components/api/api_connection.cpp | 61 ++++++++++++++++--- esphome/components/api/api_connection.h | 27 ++++---- esphome/components/api/api_server.cpp | 15 +++-- .../bluetooth_proxy/bluetooth_proxy.cpp | 8 ++- .../bluetooth_proxy/bluetooth_proxy.h | 11 ++-- .../voice_assistant/voice_assistant.cpp | 16 +++-- .../components/zwave_proxy/zwave_proxy.cpp | 12 +++- 7 files changed, 113 insertions(+), 37 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index afd7e696af..53fe40f682 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -89,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static const char *const TAG = "api.connection"; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what) { + esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"), + LOG_STR_ARG(what)); +} +#endif #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; #endif @@ -1536,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } +void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { + if (!this->send_message(msg)) { + // V: fires per decoded frame with no subscription gate, so a warning + // would flood the congested link it reports on. + ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); + } +} #endif #ifdef USE_SERIAL_PROXY @@ -1578,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM SerialProxyGetModemPinsResponse resp{}; resp.instance = msg.instance; resp.line_states = proxies[msg.instance]->get_modem_pins(); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } } void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { @@ -1610,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { resp.status = enums::SERIAL_PROXY_STATUS_ERROR; break; } - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } break; } default: @@ -1619,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { } } -void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { + if (!this->send_message(msg)) { + ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); + } +} #endif #ifdef USE_INFRARED @@ -1750,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Acknowledge the hello so the client can read the server name, then request // disconnect with the reason. Authentication is intentionally not completed. this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Hello response"); + } DisconnectRequest req; req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; return this->send_message(req); @@ -2039,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.call_id = call_id; resp.success = success; resp.error_message = error_message; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message, @@ -2050,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.error_message = error_message; resp.response_data = response_data; resp.response_data_len = response_data_len; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES +bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) { + if (!this->flags_.service_call_subscription) + return false; + if (!this->send_message(call)) { + API_LOG_MSG_DROPPED(TAG, "Action request"); + } + return true; +} +#endif // USE_API_HOMEASSISTANT_SERVICES + +#ifdef USE_HOMEASSISTANT_TIME +void APIConnection::send_time_request() { + GetTimeRequest req; + if (!this->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Time request"); + } +} +#endif // USE_HOMEASSISTANT_TIME + #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d548b921b3..bb51a13000 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -25,6 +25,7 @@ #include "esphome/components/esp8266/crash_handler.h" #endif #include "esphome/core/entity_base.h" +#include "esphome/core/log.h" #include "esphome/core/string_ref.h" #include @@ -40,6 +41,16 @@ namespace esphome::api { // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; +// One shared flash string for every refused-frame warning: send_message() +// fails as soon as the TCP buffer is full, and each caller only pays for its +// short name. The guard drops the helper and its arguments below WARN. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what); +#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what)) +#else +#define API_LOG_MSG_DROPPED(tag, what) +#endif + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase { // Returns whether this client has subscribed to Home Assistant actions; the message // is only handed to the send path when subscribed. A true return does not guarantee // delivery - it lets the caller warn when no connected client has the subscription. - bool send_homeassistant_action(const HomeassistantActionRequest &call) { - if (!this->flags_.service_call_subscription) - return false; - this->send_message(call); - return true; - } + bool send_homeassistant_action(const HomeassistantActionRequest &call); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -198,10 +204,7 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_HOMEASSISTANT_TIME - void send_time_request() { - GetTimeRequest req; - this->send_message(req); - } + void send_time_request(); #endif #ifdef USE_VOICE_ASSISTANT @@ -337,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase { // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); - template bool send_message(const T &msg) { + /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we + /// have no silent failures: every caller must handle (or log) a refusal. + template [[nodiscard]] bool send_message(const T &msg) { if constexpr (T::ESTIMATED_SIZE == 0) { return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); } else { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6e3448121c..ef5b43d7b1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -123,7 +123,9 @@ void APIServer::setup() { // Best-effort: if the send buffer is full the reason is dropped, but the // client still learns the window is closed when it reconnects (rejected at // hello) or via the socket close. - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } @@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) { void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { // We could add code to manage a second subscription type, but, since this message type is // very infrequent and small, we simply send it to all clients - for (auto &c : this->active_clients()) - c->send_message(msg); + for (auto &c : this->active_clients()) { + if (!c->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } + } } #endif @@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); for (auto &c : this->active_clients()) { DisconnectRequest req; - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 75830e83ef..1489f9f4ba 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -150,8 +150,12 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl } #endif -void BluetoothProxy::log_advertisement_flush_() { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); +void BluetoothProxy::log_advertisement_flush_(bool sent) { + if (sent) { + ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + } else { + ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); + } } void BluetoothProxy::dump_config() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e5f3a259a1..5d2605e761 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -234,16 +234,15 @@ class BluetoothProxy final : public Component { void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; - // The one deliberately ignored result: advertisements are perishable and - // this is the highest-frequency send here, so reporting each drop would be - // the flood the batch pacing exists to avoid. - this->api_connection_->send_message(this->response_); + // Perishable and the highest-frequency send here: a drop only reports at + // V, anything louder would be the flood the batch pacing exists to avoid. + [[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); + this->log_advertisement_flush_(sent); #endif this->response_.advertisements_len = 0; } - void log_advertisement_flush_(); + void log_advertisement_flush_(bool sent); #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 76ae145b16..50add6b1d0 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -248,7 +248,9 @@ void VoiceAssistant::stream_api_audio_() { msg.data2_len = available2; } - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + ESP_LOGV(TAG, "Audio frame dropped, TCP buffer full"); + } this->audio_source_->consume(available); if (this->audio_source2_ != nullptr) { @@ -477,7 +479,9 @@ void VoiceAssistant::loop() { api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } break; } } @@ -741,7 +745,9 @@ void VoiceAssistant::signal_stop_() { ESP_LOGD(TAG, "Signaling stop"); api::VoiceAssistantRequest msg; msg.start = false; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Stop request"); + } } void VoiceAssistant::start_playback_timeout_() { @@ -753,7 +759,9 @@ void VoiceAssistant::start_playback_timeout_() { return; api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } }); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 5f56861e6d..6e3f109ca1 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -166,7 +166,9 @@ void ZWaveProxy::process_uart_slow_() { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } } while (this->available()); @@ -328,7 +330,9 @@ void ZWaveProxy::send_homeid_changed_msg_(api::APIConnection *conn) { msg.data_len = this->home_id_.size(); if (conn != nullptr) { // Send to specific connection - conn->send_message(msg); + if (!conn->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } } else if (api::global_api_server != nullptr) { // We could add code to manage a second subscription type, but, since this message is // very infrequent and small, we simply send it to all clients @@ -483,7 +487,9 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->buffer_[0] = byte; this->outgoing_proto_msg_.data = this->buffer_.data(); this->outgoing_proto_msg_.data_len = 1; - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } From 1758653330b59d1050c4324a3e36394a6c94d2f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 22:59:26 -0500 Subject: [PATCH 118/597] [voice_assistant] Do not consume the audio chunk when the send is refused (#18295) --- esphome/components/voice_assistant/voice_assistant.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 50add6b1d0..dba9b925d0 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -249,7 +249,11 @@ void VoiceAssistant::stream_api_audio_() { } if (!this->api_client_->send_message(msg)) { - ESP_LOGV(TAG, "Audio frame dropped, TCP buffer full"); + // Keep the chunk exposed and retry next pass, the same shape as + // APIConnection::try_send_camera_image_(): the slice is only lost if + // the ring buffer overflows before the TCP buffer clears, instead of + // on every refusal. The api layer already reports the refusal at V. + return; } this->audio_source_->consume(available); From a4e05cd1c86fb31c146f80e99aa1f4456c18a2cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 22:59:41 -0500 Subject: [PATCH 119/597] [bluetooth_proxy] Move per-advertisement logging to very verbose (#18299) --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 1489f9f4ba..5b6f4211f2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -104,7 +104,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme this->response_.advertisements_len++; - ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { @@ -152,8 +152,10 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl void BluetoothProxy::log_advertisement_flush_(bool sent) { if (sent) { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + // VV: one line per flush drowns a verbose log in any busy environment. + ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } else { + // The rare congestion signal stays at V. ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); } } From a3d599ac699d7f51e0dd09536c8ee1d1f25602a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:00:37 -0500 Subject: [PATCH 120/597] [bluetooth_connection] Demote rp2 backend connection logs to verbose (#18301) --- .../bluetooth_connection/bluetooth_connection_hub.cpp | 4 ++++ .../bluetooth_connection/bluetooth_connection_rp2.cpp | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 50267e7c73..c8f97f207e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -133,6 +133,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int // params), so this request is normally redundant - kept as a backstop // in case the initial parameters were negotiated away. this->state_ = ClientState::ESTABLISHED; + // The one D-level line for a cached connect; the uncached path narrates + // through "Discovery finished" instead. + ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_, + this->address_str_, mtu); int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, ble_device_base::MEDIUM_CONN_TIMEOUT); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc8fb6714b..855c895196 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -542,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::MTU_EXCHANGED: if (this->state_ == EngineState::MTU_EXCHANGE) { this->mtu_ = event.value; - ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); + ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); @@ -614,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { } this->con_handle_ = con_handle; this->state_ = EngineState::MTU_EXCHANGE; - ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); + ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); BluetoothLock lock; // One wildcard listener covers notifications/indications for every // characteristic on this connection; the CCCD writes come from the API @@ -694,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) { if (this->state_ == EngineState::IDLE) { return; } - ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); + ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); this->fail_connection_(reason); } @@ -858,7 +858,7 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) { void RP2GattClient::finish_discovery_(int error) { this->discovery_phase_ = DiscoveryPhase::NONE; - ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, error, this->service_count_, this->char_count_, this->desc_count_); if (error == 0 && this->truncated_) { // A partial table must not stream: V3 clients cache the database From 5d04c1dc18a248cf2495a49fa891d6d977d6cb6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:01:45 -0500 Subject: [PATCH 121/597] [esp32_ble_tracker] Demote scan-state echoes to verbose (#18302) --- esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp | 3 ++- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++++- esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp | 3 ++- esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index a312d2496f..1b4e6245ae 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -364,7 +364,8 @@ bool BK72xxBLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // The controller reconciler restarts a running scan itself; the scan stays // logically running. An idle scanner picks the mode up on its next start. if (this->scan_running_) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 18b6cf022d..86cb7293a1 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -55,6 +55,7 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + ESP_LOGD(TAG, "Stopping scan for OTA"); this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT @@ -190,7 +191,9 @@ void ESP32BLETracker::loop() { void ESP32BLETracker::start_scan() { this->start_scan_(true); } void ESP32BLETracker::stop_scan() { - ESP_LOGD(TAG, "Stopping scan."); + // V to match the start log: the mode-switch and OTA callers narrate their + // reason at D themselves, and the user-facing stop action is deliberate. + ESP_LOGV(TAG, "Stopping scan."); this->scan_continuous_ = false; this->stop_scan_(); } diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index 11ea46525c..e1083e5fbe 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -116,7 +116,8 @@ bool LN882HBLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and // no period reset: the scan logically continues, only the mode changes. if (this->scan_running_) { diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 2a87d617f8..06beb186ae 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -165,7 +165,8 @@ bool RP2BLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // Apply to a running scan by restarting the CONTROLLER scan with the new // mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the // scan logically continues, only the request mode changes), no period reset. From 3ef74d17af6d03685939a005a81376060f7682c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:02:51 -0500 Subject: [PATCH 122/597] [bluetooth_proxy] Give partial advertisement batches 200ms to fill on Wi-Fi (#18303) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 20 +++++++++++++++++++ .../bluetooth_proxy/bluetooth_proxy.h | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 5b6f4211f2..878d3cd44e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -674,7 +674,27 @@ void BluetoothProxy::loop() { } #endif +#ifdef USE_WIFI + // Wi-Fi (or a coexistence build that can fall back to it): every other + // non-empty 100 ms tick (~200 ms) gives partial batches time to fill + // toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer, + // fuller frames. Full batches still ship immediately from the queueing + // path, and the owed-reply drains above keep the 100 ms cadence. + if (this->response_.advertisements_len != 0) { + if (this->adv_flush_toggle_) { + this->flush_pending_advertisements_(); + } + this->adv_flush_toggle_ = !this->adv_flush_toggle_; + } else { + // Nothing pending (idle, or a full batch just shipped inline): arm so + // the next batch ships on the next tick. + this->adv_flush_toggle_ = true; + } +#else + // No Wi-Fi in the build (ethernet): no airtime worth trading latency for, + // so partial batches flush every tick. this->flush_pending_advertisements_(); +#endif } void BluetoothProxy::reset_owed_replies_() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 5d2605e761..e233c38b56 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -348,6 +348,12 @@ class BluetoothProxy final : public Component { uint8_t connection_count_{0}; #endif bool configured_scan_active_{false}; // Configured scan mode from YAML +#ifdef USE_WIFI + /// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial + /// batches fill; an idle tick re-arms, so the first batch after a gap + /// still ships on the next tick. See loop(). + bool adv_flush_toggle_{false}; +#endif #ifdef USE_BLE_SCANNER_STATE_CALLBACK // A dropped push (full TX buffer) is re-queried from the hub and resent // from loop(); the hub's current state is idempotent by construction. From 08c6585915ffa2203773b4d122ad23f3a054e14f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:03:28 -0500 Subject: [PATCH 123/597] [esp32_ble_tracker] Don't log an error when a scan stop is already in flight (#18306) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 86cb7293a1..798fd6e0ca 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -202,8 +202,9 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - // If scanner is already idle, there's nothing to stop - this is not an error - if (this->scanner_state_ != ScannerState::IDLE) { + // IDLE means there is nothing to stop; STOPPING means a stop is already in + // flight and will finish on its own. Neither is an error. + if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); } return; From 8d87ba34d986a763d0328b1d242b9c50028e4c06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2026 23:04:07 -0500 Subject: [PATCH 124/597] [api] Move the generic buffer-full log to very verbose (#18300) --- esphome/components/api/api_connection.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 53fe40f682..d05f98d03b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2177,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { if (this->helper_->can_write_without_blocking()) return true; if (log_out_of_space) { - ESP_LOGV(TAG, "Cannot send message because of TCP buffer space"); + // VV: refusals are either reported by the sending call site (naming what + // was lost) or retried without loss (the deferred batch), so this generic + // line only duplicates them. + ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space"); } return false; } From cffd775450f9cdafb31b9fe741bdd04182fb53c1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 11 Aug 2026 23:21:37 -0500 Subject: [PATCH 125/597] [ethernet][network][wifi] Arbitrate the default route from the network priority list (#17797) --- .../components/ethernet/ethernet_component.h | 6 ++ .../ethernet/ethernet_component_esp32.cpp | 23 +++-- esphome/components/network/__init__.py | 41 ++++++++- .../components/network/network_component.cpp | 90 +++++++++++++++++++ .../components/network/network_component.h | 18 ++++ esphome/components/network/util.cpp | 27 ++++-- esphome/components/network/util.h | 3 +- esphome/components/wifi/wifi_component.h | 12 +++ .../wifi/wifi_component_esp_idf.cpp | 2 + esphome/core/defines.h | 1 + .../network/config/priority_arduino.yaml | 26 ++++++ .../network/config/priority_rp2040.yaml | 24 +++++ .../network/config/priority_single.yaml | 15 ++++ .../component_tests/network/test_priority.py | 71 ++++++++++++++- .../network/test-priority.esp32-ard.yaml | 23 +++++ 15 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/network/config/priority_arduino.yaml create mode 100644 tests/component_tests/network/config/priority_rp2040.yaml create mode 100644 tests/component_tests/network/config/priority_single.yaml create mode 100644 tests/components/network/test-priority.esp32-ard.yaml diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index ad329f9b81..646e0af8e6 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -140,6 +140,12 @@ class EthernetComponent final : public Component { bool is_disabled() { return this->disabled_; } bool is_enabled() { return !this->disabled_; } +#ifdef USE_ESP32 + /// esp_netif handle, used by network for default-route arbitration. + /// nullptr until the driver/netif installation has run. + esp_netif_t *get_esp_netif() { return this->eth_netif_; } +#endif + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index dc623b6e5b..0220d6a19b 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -789,16 +789,25 @@ void EthernetComponent::start_connect_() { #ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { - LwIPLock lock; + // Set DNS through esp_netif so the servers are stored in the netif's own + // dns[] array; raw dns_setserver() would be lost when the default-route + // arbitration re-applies the default netif's DNS. + // Log-only on failure: the link still has a working IP/gateway, so degraded + // name resolution does not justify marking the whole component failed. + esp_netif_dns_info_t dns{}; if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); + dns.ip = this->manual_ip_->dns1; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err)); + } } if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); + dns.ip = this->manual_ip_->dns2; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err)); + } } } else #endif diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 24e9aa45e1..3544fb2647 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -39,6 +39,12 @@ KEY_NETWORK_PRIORITY = "network_priority" # NETWORK_PLAN.md for the full multi-interface roadmap. VALID_NETWORK_TYPES = ["ethernet", "wifi"] +# Interfaces NetworkComponent::loop() knows how to arbitrate the default route +# for. Deliberately NOT derived from VALID_NETWORK_TYPES: extending that list +# without extending the C++ arbitration (and then this set) is caught in +# _final_validate() as a config error instead of a silently mis-routed interface. +ARBITRATED_NETWORK_TYPES = frozenset({"ethernet", "wifi"}) + # Setup priority base values — first in list gets the highest priority. # # The base equals the historical setup_priority::WIFI / ::ETHERNET default @@ -310,7 +316,8 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: """Check that every interface named in 'priority' has a corresponding component block.""" full = fv.full_config.get() - for entry in config.get(CONF_PRIORITY, []): + priority_list = config.get(CONF_PRIORITY, []) + for entry in priority_list: iface = entry["interface"] if iface not in full: raise cv.Invalid( @@ -319,6 +326,24 @@ def _final_validate(config: ConfigType) -> None: [CONF_PRIORITY], ) + # Tripwire for future interface types (openthread, modem): the C++ default-route + # arbitration pivots on USE_NETWORK_PRIMARY_INTERFACE_WIFI and only knows + # ethernet and wifi. Extend NetworkComponent::loop() before allowing another + # type here. Unreachable until VALID_NETWORK_TYPES grows. + if ( + len(priority_list) > 1 + and ( + unsupported := {e["interface"] for e in priority_list} + - ARBITRATED_NETWORK_TYPES + ) + and CORE.is_esp32 + ): + raise cv.Invalid( + "Default-route arbitration does not support: " + f"{', '.join(sorted(unsupported))}", + [CONF_PRIORITY], + ) + FINAL_VALIDATE_SCHEMA = _final_validate @@ -337,10 +362,22 @@ async def to_code(config): # network/util.cpp resolves the reported address (get_use_address_to, # get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority # list is the only case that deviates from it, so it is the only case that - # needs a define. Runtime (active-interface) selection is a planned follow-up. + # needs a define. if priority_list[0]["interface"] == "wifi": cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI") + # With more than one interface, NetworkComponent::loop() arbitrates the + # default route (ESP-IDF's fixed route_prio values would always favor + # WiFi). ESP32 only: the arbitration needs esp_netif, which both + # frameworks build from source. + # The ethernet/wifi-only assumption behind the arbitration is enforced in + # _final_validate() so a future unsupported type fails as a config error. + if len(priority_list) > 1 and CORE.is_esp32: + cg.add_define("USE_NETWORK_DEFAULT_ROUTE") + # Have lwIP switch to the DNS servers of the netif that owns the + # default route whenever the arbitration changes it. + add_idf_sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF", True) + _LOGGER.info( "Network interface priority: %s", " > ".join(entry["interface"] for entry in priority_list), diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index 40cf64906c..cf457bb661 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -6,6 +6,20 @@ #include "esp_err.h" #include "esp_netif.h" #include "esp_event.h" + +#ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esp_netif_net_stack.h" +#include "lwip/netif.h" +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif +#endif + namespace esphome::network { static const char *const TAG = "network"; @@ -29,5 +43,81 @@ void NetworkComponent::setup() { } } +#ifdef USE_NETWORK_DEFAULT_ROUTE +static esp_netif_t *connected_wifi_netif() { +#ifdef USE_WIFI + auto *wifi = wifi::global_wifi_component; + if (wifi != nullptr && wifi->is_connected()) + return wifi->get_esp_netif_sta(); +#endif + return nullptr; +} + +static esp_netif_t *connected_ethernet_netif() { +#ifdef USE_ETHERNET + auto *eth = ethernet::global_eth_component; + if (eth != nullptr && eth->is_connected()) + return eth->get_esp_netif(); +#endif + return nullptr; +} + +void NetworkComponent::loop() { + // Pin the default route to the first connected interface in the user's priority + // order; ESP-IDF's own route_prio selection would always favor WiFi. + // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; + // it selects the reported address in util.cpp and doubles as the route-order + // pivot here — the two uses must stay in sync. + esp_netif_t *best; +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + best = connected_wifi_netif(); + if (best == nullptr) + best = connected_ethernet_netif(); +#else + best = connected_ethernet_netif(); + if (best == nullptr) + best = connected_wifi_netif(); +#endif + if (best == nullptr) { + // Forget the last winner: stopping its netif cleared lwIP's default route and + // IDF's manual override suppresses re-election, so reconnect must re-assert it. + this->default_netif_ = nullptr; + return; + } + if (best == this->default_netif_) { + // Same winner as the last assert. Still re-assert if lwIP's default route is + // not the winner's netif: a winner whose netif bounced down and up between two + // polls would otherwise stay routeless (stopping a netif nulls lwIP's + // netif_default). Checking lwIP directly keeps this independent of IDF's + // re-election bookkeeping (esp_netif_get_default_netif() cannot detect it). + // Throttled: LwIPLock is the global lwIP core mutex, and this branch runs on + // every pass once the route has settled. + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_route_check_ < ROUTE_CHECK_INTERVAL_MS) + return; + this->last_route_check_ = now; + bool route_is_ours; + { + LwIPLock lock; + route_is_ours = static_cast(netif_default) == esp_netif_get_netif_impl(best); + } + if (route_is_ours) + return; + } + esp_err_t err = esp_netif_set_default_netif(best); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set default interface: (%d) %s", err, esp_err_to_name(err)); + // Cache the intent anyway: subsequent passes take the same-winner branch + // above, so retries are throttled to ROUTE_CHECK_INTERVAL_MS and the lwIP + // verification keeps re-attempting until the route is actually ours. + this->default_netif_ = best; + this->last_route_check_ = App.get_loop_component_start_time(); + return; + } + this->default_netif_ = best; + ESP_LOGI(TAG, "Default interface: %s", esp_netif_get_desc(best)); +} +#endif // USE_NETWORK_DEFAULT_ROUTE + } // namespace esphome::network #endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 2e76a95673..8d4866d4f0 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -3,12 +3,30 @@ #if defined(USE_NETWORK) && defined(USE_ESP32) #include "esphome/core/component.h" +#ifdef USE_NETWORK_DEFAULT_ROUTE +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::network { class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + +#ifdef USE_NETWORK_DEFAULT_ROUTE + void loop() override; + + protected: + // Verify-lwIP-route interval for the settled state; keeps the global lwIP core + // mutex off the hot loop path. + static constexpr uint32_t ROUTE_CHECK_INTERVAL_MS = 1000; + // Last netif this component made the default; avoids redundant esp_netif calls. + esp_netif_t *default_netif_{nullptr}; + uint32_t last_route_check_{0}; +#endif }; } // namespace esphome::network #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index d90c28801e..11485fdcf0 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -10,16 +10,33 @@ namespace esphome::network { // an AP that uses a previous interface for NAT). bool is_disabled() { + // The network is disabled only when every configured interface with a + // disable() lifecycle is disabled; one enabled interface means traffic can flow. + bool disabled = false; #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_disabled(); + if (modem::global_modem_component != nullptr) { + if (!modem::global_modem_component->is_disabled()) + return false; + disabled = true; + } #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_disabled(); + if (wifi::global_wifi_component != nullptr) { + if (!wifi::global_wifi_component->is_disabled()) + return false; + disabled = true; + } #endif - return false; + +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) { + if (!ethernet::global_eth_component->is_disabled()) + return false; + disabled = true; + } +#endif + return disabled; } const char *get_use_address_to(std::span buf) { diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index df7e164bda..65a578c22f 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -52,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return false; } -/// Return whether the network is disabled (only wifi for now) +/// Return whether the network is disabled: every configured interface with a +/// disable() lifecycle (modem, wifi, ethernet) is disabled. bool is_disabled(); /// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a851ea4015..ea043fd5c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -65,6 +65,12 @@ extern "C" { #include #endif +#ifdef USE_ESP32 +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this widely-included header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::wifi { /// Sentinel value for RSSI when WiFi is not connected @@ -469,6 +475,12 @@ class WiFiComponent final : public Component { bool is_connected() const { return this->connected_; } +#ifdef USE_ESP32 + /// esp_netif handle of the station interface, used by network for default-route + /// arbitration. nullptr until wifi_lazy_init_() has run. + esp_netif_t *get_esp_netif_sta(); +#endif + void set_power_save_mode(WiFiPowerSaveMode power_save); void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } void set_output_power(float output_power) { output_power_ = output_power; } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 0198f899d5..245390b097 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -620,6 +620,8 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return true; } +esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } + network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { if (!this->has_sta()) return {}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 26b9025d96..bb4960aec7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -138,6 +138,7 @@ #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK +#define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE diff --git a/tests/component_tests/network/config/priority_arduino.yaml b/tests/component_tests/network/config/priority_arduino.yaml new file mode 100644 index 0000000000..b66f676601 --- /dev/null +++ b/tests/component_tests/network/config/priority_arduino.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_rp2040.yaml b/tests/component_tests/network/config/priority_rp2040.yaml new file mode 100644 index 0000000000..984f2dcbb4 --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_single.yaml b/tests/component_tests/network/config/priority_single.yaml new file mode 100644 index 0000000000..bd23697808 --- /dev/null +++ b/tests/component_tests/network/config/priority_single.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + priority: + - wifi diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py index da1c0a061d..017f0711a3 100644 --- a/tests/component_tests/network/test_priority.py +++ b/tests/component_tests/network/test_priority.py @@ -16,9 +16,10 @@ from esphome.components.network import ( _validate_priority_list, get_network_priority, ) -from esphome.const import CONF_PRIORITY +from esphome.const import CONF_PRIORITY, PlatformFramework from esphome.core import CORE import esphome.final_validate as fv +from tests.component_tests.types import SetCoreConfigCallable @pytest.fixture(autouse=True) @@ -138,6 +139,22 @@ def test_final_validate_noop_without_priority_list() -> None: _final_validate({}) # must not raise +def test_final_validate_rejects_unsupported_arbitration_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + """The ethernet/wifi-only arbitration tripwire fails as a clean config error. + + Unreachable through the public schema today (VALID_NETWORK_TYPES gates the + list), so the config is hand-built to simulate a future interface type that + was added to the schema without extending NetworkComponent::loop(). + """ + set_core_config(PlatformFramework.ESP32_IDF) + fv.full_config.set({"openthread": {}, "wifi": {}}) + config = {CONF_PRIORITY: [{"interface": "openthread"}, {"interface": "wifi"}]} + with pytest.raises(Invalid, match="arbitration does not support: openthread"): + _final_validate(config) + + def _cpp_setup_priority(name: str) -> float: """Read a setup_priority constant straight from esphome/core/component.h.""" header = Path(__file__).parents[3] / "esphome" / "core" / "component.h" @@ -199,3 +216,55 @@ def test_no_primary_interface_define_without_priority( assert not any( d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines ) + + +def _dns_per_default_netif_option() -> bool | None: + from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS + + if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all + return None + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get( + "CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF" + ) + + +@pytest.mark.parametrize( + "config_file", + [ + "priority_wifi_first.yaml", + "priority_ethernet_first.yaml", + "priority_arduino.yaml", + ], +) +def test_multi_interface_priority_enables_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """More than one interface in 'priority' enables default-route arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is True + + +@pytest.mark.parametrize( + "config_file", + [ + # Single-entry priority list / no list at all. + "priority_single.yaml", + "wifi_only.yaml", + # Dual-interface on rp2040: validates, but the arbitration is ESP32-only + # (NetworkComponent::loop() is compiled under USE_ESP32) — emitting the + # define here would be a hard build break. + "priority_rp2040.yaml", + ], +) +def test_single_interface_has_no_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Single-interface and non-ESP32 configs must not compile in the arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" not in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is None diff --git a/tests/components/network/test-priority.esp32-ard.yaml b/tests/components/network/test-priority.esp32-ard.yaml new file mode 100644 index 0000000000..a04246a128 --- /dev/null +++ b/tests/components/network/test-priority.esp32-ard.yaml @@ -0,0 +1,23 @@ +# Arduino dual-stack test: default-route arbitration must also compile under +# the Arduino framework, which builds the same esp_netif/ESP-IDF from source. +# Ethernet is listed first so this build exercises the ethernet-first side of +# the arbitration pivot in NetworkComponent::loop() (the IDF variant of this +# test covers the wifi-first side). +wifi: + ssid: MySSID + password: password1 + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi From 622942482cf79818396e2db38f6e7ea717b4a7eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 00:30:17 -0500 Subject: [PATCH 126/597] [rp2] Size the lwIP segment pool and heap for concurrent senders (#18257) --- esphome/components/api/api_frame_helper.h | 4 +- esphome/components/rp2/__init__.py | 150 ++++++++++++++-------- esphome/components/rp2/lwipopts.h.jinja | 13 +- tests/unit_tests/components/test_rp2.py | 107 +++++++++++++-- 4 files changed, 207 insertions(+), 67 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9cae6ba92e..9c49956bbd 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -149,7 +149,7 @@ class APIFrameHelper { // holding data too long waiting for Nagle's timer causes buffer exhaustion // and dropped messages. // - // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) // // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) @@ -312,7 +312,7 @@ class APIFrameHelper { // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. - // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. + // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more. #ifdef USE_ESP8266 static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 3bc2df7a61..87e78003ed 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -388,6 +388,74 @@ async def to_code(config): _configure_lwip() +# --- lwIP sizing. See _configure_lwip() for the platform comparison table. --- + +# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. +# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. +LWIP_TCP_SND_BUF = "(4*TCP_MSS)" + +# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. +LWIP_TCP_WND = "(4*TCP_MSS)" + +# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer +# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS +# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 +LWIP_TCP_SND_QUEUELEN = 17 + +# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB +# queue length — lwIP's sanity check only demands >=, the floor for a single +# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured +# at 20 bytes per entry, so under 700 bytes total. +LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN + +# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. +# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path +# copies into PBUF_RAM out of MEM_SIZE. +LWIP_PBUF_POOL_SIZE = 16 + +# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing +# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full +# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 + +# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB. +# +# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c +# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75% +# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well +# before the total does — hence the intermittent failures. With rp2's +# max_connections of 4, a third sender has nothing left. +# +# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards). +# Must stay under 64000 or lwIP widens mem_size_t to u32_t. +LWIP_MEM_SIZE = 32768 + + +def build_lwip_defines( + tcp_sockets: int, udp_sockets: int, listening_tcp: int +) -> dict[str, str]: + """Render the lwIP override values for the Jinja2 template. + + The template uses #include_next to chain to the framework's original + lwipopts.h, then #undef/#define only these. Split out from + _configure_lwip() so the values that actually reach the generated header + can be checked without standing up CORE. + + Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The + static pools are the only IRQ-safe allocator on this platform, so the fix + is to size them correctly rather than to make them dynamic. + """ + return { + "TCP_SND_BUF": LWIP_TCP_SND_BUF, + "TCP_WND": LWIP_TCP_WND, + "TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN), + "MEM_SIZE": str(LWIP_MEM_SIZE), + "MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG), + "PBUF_POOL_SIZE": str(LWIP_PBUF_POOL_SIZE), + "MEMP_NUM_TCP_PCB": str(tcp_sockets), + "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), + "MEMP_NUM_UDP_PCB": str(udp_sockets), + } + + def _configure_lwip() -> None: """Configure lwIP options for RP2040 by generating a custom lwipopts.h. @@ -407,25 +475,36 @@ def _configure_lwip() -> None: ──────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + TCP_SND_QUEUELEN ~8 17 32 17 MEM_LIBC_MALLOC 1 1 0 0* MEMP_MEM_MALLOC 1 1 0 0** - MEM_SIZE N/A*** N/A*** 16KB 16KB + MEM_SIZE N/A*** N/A*** 16KB 32KB PBUF_POOL_SIZE 10 16 24 16 - MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_SEG 10 16 32 34**** MEMP_NUM_TCP_PCB 5 16 5 dynamic - MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic + MEMP_NUM_TCP_PCB_LISTEN 4 16 8***** dynamic MEMP_NUM_UDP_PCB 4 16 7 dynamic - TCP_SND_QUEUELEN ~8 17 32 17 * MEM_LIBC_MALLOC must stay 0: arduino-pico uses PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from a low-priority pendsv IRQ. The pico-sdk explicitly blocks MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ). - ** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB) - is too small to hold all pools dynamically. The PBUF_POOL alone needs - ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings. - *** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). - **** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. + ** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc() + pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes + its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0), + so under NO_SYS=1 mem_malloc()/mem_free() are unprotected — and memp.c + calls mem_malloc() outside the guard anyway. RX pbufs would then be + allocated from the pendsv IRQ on the same unguarded free list the main + loop uses for tcp_write(). Tried on hardware: faults within seconds on + CYW43. Ethernet survives only because it polls from the main loop. + *** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from + the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps + (MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are + 0 here, so ours are hard limits; don't copy their numbers. + **** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so + sizing it to the per-PCB value lets one busy connection drain it for + every other. 2× covers two PCBs; MEM_SIZE is the real limit past that. + ***** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. "dynamic" = auto-calculated from component socket registrations via socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN. """ @@ -444,48 +523,7 @@ def _configure_lwip() -> None: # UDP PCBs (2) are absorbed by the generous minimum of 6. listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen) - # TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. - # ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. - tcp_snd_buf = "(4*TCP_MSS)" - - # TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. - tcp_wnd = "(4*TCP_MSS)" - - # TCP_SND_QUEUELEN: max pbufs queued for send buffer - # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS - # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 - tcp_snd_queuelen = 17 - # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) - memp_num_tcp_seg = tcp_snd_queuelen - - # PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. - # 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1, - # this is a max count (allocated on demand from heap). - pbuf_pool_size = 16 - - # Build the lwIP override defines for the Jinja2 template. - # The template uses #include_next to chain to the framework's original - # lwipopts.h, then #undef/#define only the values we need to change. - # - # Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp - # allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE - # is too small to hold all pools dynamically under stress. The PBUF_POOL - # alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate - # the BSS savings. - # - # MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses - # PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from - # a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe. - lwip_defines: dict[str, str] = { - "TCP_SND_BUF": tcp_snd_buf, - "TCP_WND": tcp_wnd, - "TCP_SND_QUEUELEN": str(tcp_snd_queuelen), - "MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg), - "PBUF_POOL_SIZE": str(pbuf_pool_size), - "MEMP_NUM_TCP_PCB": str(tcp_sockets), - "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), - "MEMP_NUM_UDP_PCB": str(udp_sockets), - } + lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp) # Store for copy_files() to generate the header CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines @@ -500,7 +538,8 @@ def _configure_lwip() -> None: udp_min = " (min)" if udp_sockets > sc.udp else "" listen_min = " (min)" if listening_tcp > sc.tcp_listen else "" _LOGGER.info( - "Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + "Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + LWIP_MEM_SIZE, tcp_sockets, tcp_min, sc.tcp_details, @@ -521,7 +560,7 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment + from jinja2 import Environment, StrictUndefined lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: @@ -534,7 +573,10 @@ def _generate_lwipopts_h() -> None: template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( encoding="utf-8" ) - jinja_env = Environment(keep_trailing_newline=True) + # StrictUndefined: a placeholder with no value would otherwise render + # empty, emitting a bare #define that compiles and silently means + # something else in lwIP's config. + jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined) template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) diff --git a/esphome/components/rp2/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja index 36d7d4da14..2da4f467a9 100644 --- a/esphome/components/rp2/lwipopts.h.jinja +++ b/esphome/components/rp2/lwipopts.h.jinja @@ -20,13 +20,24 @@ #undef TCP_WND #define TCP_WND {{ TCP_WND }} -// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32 #undef TCP_SND_QUEUELEN #define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} +// Segment pool: global across every PCB, so it is sized above the per-PCB +// queue length rather than equal to it. lwIP's sanity check only requires +// >= TCP_SND_QUEUELEN, which is the floor for a single connection. #undef MEMP_NUM_TCP_SEG #define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} +// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into. +// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB +// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at +// 75%, and mem.c is first-fit, so the largest contiguous run ran out well +// before the total did. +#undef MEM_SIZE +#define MEM_SIZE {{ MEM_SIZE }} + // Packet buffer pool: 16 matches ESP32 (down from 24) #undef PBUF_POOL_SIZE #define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py index 023d926dc4..cd92bc24fa 100644 --- a/tests/unit_tests/components/test_rp2.py +++ b/tests/unit_tests/components/test_rp2.py @@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered by the framework tests under ``tests/unit_tests/``. """ +from pathlib import Path +import re + +from esphome.components import rp2 + def test_board_id_has_wifi_for_known_wifi_board() -> None: """``rpipicow`` is the canonical Pico W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipicow") is True def test_board_id_has_wifi_for_known_non_wifi_board() -> None: """Plain ``rpipico`` has no CYW43 → False.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico") is False def test_board_id_has_wifi_for_rp2350_w_variant() -> None: """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico2w") is True @@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: block and any genuinely-unsupported config trips the existing "no CYW43" guard at compile time. """ - from esphome.components import rp2 - assert rp2.board_id_has_wifi("not-a-real-board-id") is True @@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None: opts in via ``ALIASES``; without this declaration the rename framework wouldn't route legacy configs. """ - from esphome.components import rp2 - assert "rp2040" in rp2.ALIASES assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" @@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: assert rp2040_boards is rp2_boards assert rp2040_generate is rp2_generate + + +def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None: + """The segment pool is global while the send queue is per-PCB. + + lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``, + which is the floor for a *single* connection: at equality one busy PCB can + drain the pool for every other PCB. Dropping back to that floor would + rebuild the starvation this sizing exists to prevent, and nothing in the + build would complain. + """ + assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN + + +def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None: + """``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on + ``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the + heap past that bound is a real option, but it should be a deliberate one + rather than a side effect of tuning. + """ + assert rp2.LWIP_MEM_SIZE <= 64000 + + +def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None: + """Pin the floor as well as the ceiling. + + The ceiling above is satisfied by arduino-pico's own 16 KB, which is the + value this change exists to move off, so on its own it would let a revert + through. Derive the floor from the sizing comment on the constant: with + TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block + (pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB), + a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's + max_connections on rp2 is 4. Room for three concurrent senders is the + minimum that makes the change worth making; 16 KB does not reach it. + """ + segments_per_full_send_buf = 4 + bytes_per_mss_block = 1536 + concurrent_senders = 3 + + assert ( + concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block + <= rp2.LWIP_MEM_SIZE + ) + + +def test_lwip_defines_carry_the_sizing_into_the_header() -> None: + """The constants above only matter if they reach the generated header. + + ``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it + rather than on the constants alone: dropping a key here would silently + fall back to arduino-pico's own value while every other assertion in this + file stayed green. + """ + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + + assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE) + assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG) + assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN) + # Socket-derived counts pass through untouched. + assert defines["MEMP_NUM_TCP_PCB"] == "8" + assert defines["MEMP_NUM_UDP_PCB"] == "6" + assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2" + + +def test_lwipopts_template_renders_every_sizing_value() -> None: + """Render the template the way _generate_lwipopts_h() does and check the + header that actually ships. + + Covers both directions. A ``#define`` block deleted from the template + leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB + heap this change exists to move off, and the loop below catches that. A + placeholder with no dict key would otherwise render empty and emit a bare + ``#define FOO``; StrictUndefined turns that into an error instead. + Matching on text also survives a filter or conditional appearing in the + template later, which a placeholder regex would not. + """ + from jinja2 import Environment, StrictUndefined + + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" + ) + rendered = ( + Environment(keep_trailing_newline=True, undefined=StrictUndefined) + .from_string(template_text) + .render(**defines) + ) + + for name, value in defines.items(): + assert re.search( + rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE + ), f"{name} did not reach the generated header as {value!r}" From 25c0c2c97b1a9ff45b7213c048d3b12dc6720d39 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Aug 2026 08:10:23 +0200 Subject: [PATCH 127/597] [hoermann_hcp] Add garage light control (#18190) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/hoermann_hcp/hoermann_hcp.cpp | 148 +++- .../components/hoermann_hcp/hoermann_hcp.h | 39 +- .../components/hoermann_hcp/light/__init__.py | 24 + .../hoermann_hcp/light/hoermann_hcp_light.cpp | 82 ++ .../hoermann_hcp/light/hoermann_hcp_light.h | 30 + .../hoermann_hcp_binary_sensor_test.cpp | 30 +- tests/components/hoermann_hcp/common.h | 68 ++ tests/components/hoermann_hcp/common.yaml | 4 + .../cover/hoermann_hcp_cover_test.cpp | 57 +- .../hoermann_hcp/hoermann_hcp_test.cpp | 134 ++- .../light/hoermann_hcp_light_test.cpp | 761 ++++++++++++++++++ 11 files changed, 1211 insertions(+), 166 deletions(-) create mode 100644 esphome/components/hoermann_hcp/light/__init__.py create mode 100644 esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp create mode 100644 esphome/components/hoermann_hcp/light/hoermann_hcp_light.h create mode 100644 tests/components/hoermann_hcp/common.h create mode 100644 tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 0dc146a061..a780854831 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -13,10 +13,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; // High byte of the state register and the door state it stands for. State 0x00 is decoded separately because // its low byte tells a plain stop from the vent position. @@ -58,17 +65,29 @@ void HoermannHcp::update() { // Status broadcasts alone keep the connection alive, so a command the controller never fetches would // otherwise block every later one for as long as it keeps broadcasting. if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { - ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); - this->next_command_ = nullptr; - this->command_written_at_ = 0; - this->clear_target_(); + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; } // A target waits for a door still travelling the other way to turn around. If it never does, the target has // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. - if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); this->clear_target_(); } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, this->on_state_reg_(registers[2]); if (registers.size() > 1) this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } return {}; } @@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { this->command_written_at_ = millis(); ESP_LOGI(TAG, "Sending '%s' command to door", command->name); registers.push_back(command->pressed_value); - registers.push_back(0x0000); + registers.push_back(command->pressed_value_2); return; } if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { - // Still inside the key-press window, so keep presenting 0x0000. + // Between the two events there is nothing to report, including in the second register. push_zeros(registers, 2); return; } @@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { ESP_LOGD(TAG, "Released '%s' command", command->name); this->command_written_at_ = 0; this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); registers.push_back(command->released_value); - registers.push_back(0x0000); + registers.push_back(command->released_value_2); } void HoermannHcp::on_position_reg_(uint16_t value) { @@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) { ESP_LOGW(TAG, "Unknown door state 0x%02X", state); } +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { if (!this->valid_) { // Queueing now would fire the command whenever the controller comes back, which may be much later. @@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { return false; } // A new command supersedes any half-open target the door was still travelling to. - this->clear_target_(); + if (command.clears_target) + this->clear_target_(); this->next_command_ = &command; this->command_queued_at_ = millis(); return true; @@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} bool HoermannHcp::stop_door() { if (!is_moving(this->door_state_)) { @@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) { if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) return false; this->target_position_ = position; + this->target_queued_at_ = millis(); this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; // A door already travelling that way is on its way; one moving the other way has to turn around first. this->target_started_ = this->door_state_ == this->target_direction_; @@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) { } ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. this->next_command_ = nullptr; this->command_written_at_ = 0; - this->clear_target_(); + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; } void HoermannHcp::set_door_state_(DoorState state) { @@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() { this->target_started_ = false; } +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 142365f16e..41fd7617e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -22,11 +22,15 @@ enum class DoorState : uint8_t { }; // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a -// short delay the released value. The second command register remains zero. +// short delay the released value. Each half also carries a second register, which only the lamp command uses. struct HoermannHcpCommand { const char *name; uint16_t pressed_value; uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { @@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool impulse_door(); bool stop_door(); bool set_position(float position); + bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } float get_current_position() const { return this->current_position_; } bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; void record_response_(); // Returns false when the bus controller has not fetched the previous command yet. bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); // Appends the two key-press registers and advances the pending command's press/release state. void push_command_registers_(modbus::RegisterValues ®isters); void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void update_current_position_(); bool has_target_() const { return this->target_position_ != 0.0f; } void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); CallbackManager state_callback_; @@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { // Pending command / key-press state machine. const HoermannHcpCommand *next_command_{nullptr}; uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; uint32_t command_written_at_{0}; uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; // A command is "pressed" for this long before its end value is sent. uint16_t key_press_delay_ms_{100}; @@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { DoorState target_direction_{DoorState::STOPPED}; // Position as reported by the bus controller, 0..200 across the full travel. uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; bool target_started_{false}; bool valid_{false}; bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 3cf708c19e..6e9b567080 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -2,29 +2,9 @@ #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// Exposes the connection bookkeeping so a drop can be driven without waiting one out. -class TestableHoermannHcp : public HoermannHcp { - public: - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { @@ -42,7 +22,7 @@ TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { sensor.setup(); ASSERT_FALSE(sensor.state); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); EXPECT_TRUE(sensor.state); @@ -59,7 +39,7 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { int publishes = 0; sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + connect_controller(door); door.update(); ASSERT_EQ(publishes, 1); @@ -69,4 +49,4 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h new file mode 100644 index 0000000000..a6151697f0 --- /dev/null +++ b/tests/components/hoermann_hcp/common.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp::testing { + +using modbus::RegisterValues; + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +inline RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// A status broadcast carrying the lamp register, which the door reports at index 6. +inline RegisterValues lamp_broadcast(uint16_t lamp_reg) { + return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg}); +} + +// The door only accepts commands once the bus controller has actually talked to it. +inline void connect_controller(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + if (response.size() != 8u) + return {0xFFFF, 0xFFFF}; + return {response[2], response[3]}; +} + +// Presents and then releases the queued command, leaving the slot free. +inline void consume_command(HoermannHcp &door) { + poll_command(door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::light_toggle_released_at_; + using HoermannHcp::light_toggles_in_flight_; + using HoermannHcp::set_valid_; +}; + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 84162e8812..552b1cb0fd 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,3 +11,7 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + +light: + - platform: hoermann_hcp + name: Garage Light diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp index 0ec2ed1ddd..43ca47edb2 100644 --- a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -2,36 +2,9 @@ #include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" -namespace esphome::hoermann_hcp { +#include "../common.h" -using modbus::RegisterValues; - -namespace { - -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The door only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -} // namespace +namespace esphome::hoermann_hcp::testing { // Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { @@ -92,10 +65,10 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_open().perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // The same for cover.close, which arrives as a position of 0.0. @@ -103,32 +76,32 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_command_toggle().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { HoermannHcp door; HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); // The door is opening, so it takes an impulse to stop it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); cover.make_call().set_command_stop().perform(); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A position between the end stops starts the door in the right direction; it is stopped there later. @@ -136,10 +109,10 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { HoermannHcp door; // starts out fully closed HoermannHcpCover cover(&door); cover.setup(); - connect(door); + connect_controller(door); cover.make_call().set_position(0.5f).perform(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed } // A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has @@ -153,7 +126,7 @@ TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { cover.make_call().set_command_close().perform(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); EXPECT_EQ(publishes, 1); EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); } @@ -166,9 +139,9 @@ TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { cover.setup(); EXPECT_TRUE(cover.status_has_warning()); - connect(door); + connect_controller(door); door.update(); EXPECT_FALSE(cover.status_has_warning()); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp index 8463c3f605..1cc5301b4a 100644 --- a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -3,51 +3,9 @@ #include #include -#include "esphome/components/hoermann_hcp/hoermann_hcp.h" +#include "common.h" -namespace esphome::hoermann_hcp { - -using modbus::RegisterValues; - -namespace { - -// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). -constexpr uint16_t COMMAND_REG = 0x9C41; -constexpr uint16_t STATE_REG = 0x9CB9; -constexpr uint16_t BROADCAST_REG = 0x9D31; - -// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. -constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); - -RegisterValues make_registers(std::initializer_list values) { - RegisterValues registers; - for (uint16_t value : values) - registers.push_back(value); - return registers; -} - -// The device only accepts commands once the bus controller has actually talked to it. -void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } - -// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. -uint16_t poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); - RegisterValues response; - door.on_read_holding_registers(STATE_REG, 8, response); - EXPECT_EQ(response.size(), 8u); - return response.size() == 8u ? response[2] : 0xFFFF; -} - -// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. -class TestableHoermannHcp : public HoermannHcp { - public: - TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } - - using HoermannHcp::connection_timeout_ms_; - using HoermannHcp::set_valid_; -}; - -} // namespace +namespace esphome::hoermann_hcp::testing { // An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { @@ -91,7 +49,7 @@ TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { // A queued control command is injected into the next command poll as a simulated key press. TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { HoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); RegisterValues response; @@ -113,31 +71,31 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { // A command is held for the key-press duration, then released, and only then can the next one be queued. TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. door.close_door(); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // With the command gone, the next one is accepted again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed } // Commands issued while the bus controller is absent are dropped instead of firing when it returns. TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { HoermannHcp door; door.open_door(); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Losing the controller must drop a command it never fetched, otherwise it blocks every later command // and fires unasked once the bus comes back. TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.open_door(); ASSERT_TRUE(door.is_valid()); @@ -145,10 +103,10 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { EXPECT_FALSE(door.is_valid()); // The reconnecting poll must not replay the dropped command. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // And the slot is free, so a new command is accepted. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The connection is dropped by update() once the controller stops polling, which is what releases a @@ -157,7 +115,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TestableHoermannHcp door; // Wide enough that a stall cannot expire the connection before the check below runs. door.connection_timeout_ms_ = 10000; - connect(door); + connect_controller(door); door.open_door(); // Still inside the window: the controller counts as present. @@ -170,7 +128,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { door.update(); EXPECT_FALSE(door.is_valid()); // The pending command went with the connection instead of firing on the reconnecting poll. - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // Status broadcasts alone keep the connection alive, so a command the controller never fetches has to @@ -178,7 +136,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.open_door(); std::this_thread::sleep_for(std::chrono::milliseconds(220)); @@ -189,7 +147,7 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { // With the stale command gone, the door accepts commands again. door.close_door(); - EXPECT_EQ(poll_command(door), 0x0220); + EXPECT_EQ(poll_command(door).first, 0x0220); } // The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed @@ -272,7 +230,7 @@ TEST(HoermannHcpWrite, EndStopsReportExactPositions) { // A position request below the lower snap threshold becomes a plain close command. TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { HoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.02f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -283,7 +241,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { // A half-open target starts the door moving towards the requested position. TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { HoermannHcp door; // starts out fully closed - connect(door); + connect_controller(door); door.set_position(0.5f); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); @@ -294,31 +252,31 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { // The door has no notion of a target, so it is stopped with an impulse once it travels past the request. TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Position 120/200 = 0.6 is past the target, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once // must be read as "already stopped" rather than "still opening". TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); ASSERT_EQ(door.get_door_state(), DoorState::OPENING); @@ -326,17 +284,17 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); ASSERT_EQ(door.get_door_state(), DoorState::OPEN); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The door is stopped at 0.3 by a wall button, short of the requested 0.5. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); @@ -346,48 +304,48 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } // A target armed while the door is still travelling the other way must not be judged by that old direction, // otherwise the very next position it reports counts as reached and stops the door where it stands. TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { TestableHoermannHcp door; - connect(door); + connect_controller(door); // The door is closing, passing 60/200 = 0.3. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released // Still closing at 58/200 = 0.29: below the target, but not on the way to it. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Now opening at 62/200 = 0.31, still short of the target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); // Past the target at 110/200 = 0.55, so the door is stopped. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed } // A motor turning around can report a momentary stop; dropping the target there would let the door run on // to the end stop that the reversing command asked for. TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { TestableHoermannHcp door; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); // The stop reported on the way from closing to opening. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); @@ -395,23 +353,23 @@ TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { // The door then opens and still has to be stopped at the requested position. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0240); + EXPECT_EQ(poll_command(door).first, 0x0240); } // A door that never turns around has to lose the target as well, otherwise it would cut a later move short. TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { TestableHoermannHcp door; door.connection_timeout_ms_ = 200; - connect(door); + connect_controller(door); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); door.set_position(0.5f); - EXPECT_EQ(poll_command(door), 0x0210); + EXPECT_EQ(poll_command(door).first, 0x0210); std::this_thread::sleep_for(KEY_PRESS_ELAPSED); - EXPECT_EQ(poll_command(door), 0x0110); + EXPECT_EQ(poll_command(door).first, 0x0110); std::this_thread::sleep_for(std::chrono::milliseconds(220)); // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the @@ -424,7 +382,7 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { // A later manual open must run freely instead of being stopped at the abandoned target. door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); - EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(poll_command(door).first, 0x0000); } -} // namespace esphome::hoermann_hcp +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp new file mode 100644 index 0000000000..ed7e81b279 --- /dev/null +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -0,0 +1,761 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible. +class CountingHoermannHcpLight : public HoermannHcpLight { + public: + using HoermannHcpLight::HoermannHcpLight; + + void write_state(light::LightState *state) override { + this->writes++; + HoermannHcpLight::write_state(state); + } + + int writes{0}; +}; + +// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +struct LightFixture { + TestableHoermannHcp door; + CountingHoermannHcpLight output{&door}; + light::LightState state{&output}; + + explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { + this->state.set_restore_mode(restore_mode); + this->output.setup(); + // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the + // boot ordering tests need to be able to place around the bus controller coming up. + this->state.setup(); + } + + // Brings the bus controller up and lets the platform read the lamp once, which is what a device does before + // any user command can arrive. + void bring_up() { + connect_controller(this->door); + this->report_lamp(false); + } + + // Issues a command the way Home Assistant would, then lets the state machine settle. + void command(bool on) { + auto call = this->state.make_call(); + call.set_state(on); + call.perform(); + this->settle(); + } + + // Delivers a status broadcast and runs the hub's notification pass. + void report_broadcast(const RegisterValues ®isters) { + this->door.on_write_registers(BROADCAST_REG, registers); + this->pump(); + } + + void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); } + + // Runs the hub's notification pass and lets the resulting publishes settle. + void pump() { + this->door.update(); + this->settle(); + } + + void settle() { + for (int i = 0; i < 4; i++) + this->state.loop(); + } + + bool entity_on() { return this->state.remote_values.is_on(); } +}; + +} // namespace + +// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit. +TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) { + HoermannHcp door; + EXPECT_FALSE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014)); + EXPECT_TRUE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(door.is_light_on()); +} + +// The lamp command is the only one that drives the second command register, on both halves of the press. +TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) { + TestableHoermannHcp door; + connect_controller(door); + ASSERT_FALSE(door.is_light_on()); + ASSERT_TRUE(door.toggle_light()); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0800); + EXPECT_EQ(released_2, 0x0200); + + // The command is spent, so the next poll carries nothing. + auto [idle, idle_2] = poll_command(door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// Toggling the lamp must not disturb a cover position the door is still travelling to. +TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + + // Past the target: the door still has to be stopped despite the lamp command in between. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched +// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the +// door a little overshoot but never loses the stop. +TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + // The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); + + // The target survived the refusal, so the next position report still stops the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100})); + auto [stop, stop_2] = poll_command(door); + EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(stop_2, 0x0000); +} + +// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive. +TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // The door is closing, so an opening target is armed but not yet under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + door.update(); + + // The target expired on its own schedule, so a later opening move runs freely. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Without a bus controller the command cannot be delivered, and the caller is told. +TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) { + HoermannHcp door; + EXPECT_FALSE(door.toggle_light()); +} + +// Switching the entity on sends one toggle, and the door's own report does not send a second. +TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, clearing the slot + + // The lamp is now on, and the resulting broadcast must not queue another toggle. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which +// would cancel the user's own command. +TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + // A door movement sets changed_, firing the state callback while the toggle is still queued. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp switched on at the door itself has to reach the entity. +TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_FALSE(fixture.entity_on()); + + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refused command must leave the entity showing the lamp, not the request. +TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) { + LightFixture fixture; // never connected, so the hub refuses every command + + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up +// showing the lamp rather than the request that was refused. +TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_TRUE(fixture.entity_on()); + + // A door movement while the refused toggle is still on the wire must not pull the entity back either. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_TRUE(fixture.entity_on()); + + // The toggle lands and the door confirms it; the entity must already agree. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in +// that gap must not publish the state the lamp is about to leave. +TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, so nothing is pending any more + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported + + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.entity_on()); +} + +// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp +// moving, so the entity has to be brought back to what the lamp actually is. +TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + // The controller keeps broadcasting but never fetches the command, so the connection stays up. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + fixture.pump(); + + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); +} + +// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the +// controller is back and still reporting the lamp unchanged. +TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); // the connection times out and the command goes with it + ASSERT_FALSE(fixture.door.is_valid()); + + connect_controller(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not +// decide whether a toggle is needed after it. +TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + fixture.report_lamp(true); + ASSERT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Back on the bus, but nothing has said what the lamp is doing yet. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(false); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy. +TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + EXPECT_TRUE(fixture.output.status_has_warning()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.output.status_has_warning()); +} + +// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and +// withdraw the one still waiting rather than deciding nothing is needed. +TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // the first toggle is released but not reported back + fixture.command(false); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + + // Two toggles cancel out, so asking for on again means withdrawing the second one. + fixture.command(true); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1); + EXPECT_TRUE(fixture.entity_on()); +} + +// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll +// must not be mistaken for it and swallowed. +TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); // the boot replay lands here, while the lamp is still unknown + + // The first status broadcast arrives, but the hub has not polled yet, so no callback has fired. + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp +// that is already on must not be switched off by that replay. +TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller is already up and reporting the lamp lit before the entity's first loop. + connect_controller(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_TRUE(fixture.door.is_light_on()); + + fixture.settle(); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + // Once the platform has read the lamp the entity follows it, still without commanding anything. + fixture.pump(); + EXPECT_TRUE(fixture.entity_on()); +} + +// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before +// the first status broadcast must not be judged against a lamp state that was never read. +TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller polls for commands, which is enough to connect but carries no lamp register. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(true); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet. +// A reversing request in that window is a real request and has to be sent, not swallowed. +TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); + + fixture.command(false); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); + EXPECT_FALSE(fixture.entity_on()); + + // The first toggle lands and is reported, but the entity is already heading for off. + fixture.report_lamp(true); + EXPECT_FALSE(fixture.entity_on()); + + // The second toggle lands too, and the lamp finally agrees with the request. + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity +// against the next lamp change the door reports. +TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Refused because the bus is down, so no toggle is heading for the lamp. + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); + + // The controller returns and reports the lamp switched on at the door itself. + connect_controller(fixture.door); + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone. +TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + // The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own. + ASSERT_TRUE(door.toggle_light()); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.update(); + + // The target survived the lamp toggle being dropped, so the door is still stopped on the way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A door that takes the key press but never actually switches the lamp must not leave the entity showing the +// request for ever; the wait has to end so the entity can settle back on what the door reports. +TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the door takes press and release, then does nothing + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); // the lamp is still off, and keeps saying so + EXPECT_FALSE(fixture.entity_on()); +} + +// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that +// counts as a change the light never hears about it and swallows the first command. +TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) { + LightFixture fixture; + // A command poll connects the controller without saying anything about the lamp. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // Closed, at rest, lamp off: every field matches the defaults the hub started with. + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000})); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards. +// Which command happened to be in the slot must not change that. +TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + ASSERT_TRUE(door.toggle_light()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + ASSERT_FALSE(door.is_valid()); + + // Back on the bus and travelling past where the target was: nothing should stop the door now. + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door +// that never reports the lamp would leave the entity waiting for ever. +TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the first toggle is released but never reported back + fixture.command(false); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + fixture.command(true); // withdraws the second, leaving the first outstanding + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1); + + // The door still says nothing about the lamp, so the wait has to time out on its own. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_FALSE(fixture.entity_on()); +} + +// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would +// re-enter write_state() on every loop, so the platform would never stop asking to be written. +TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed. + fixture.command(false); + const int settled_writes = fixture.output.writes; + fixture.settle(); + EXPECT_EQ(fixture.output.writes, settled_writes); +} + +// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing +// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning +// the next toggle the moment it is queued. +TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + poll_command(door); // the door is shown the key press + + // The door acts on it and reports the lamp straight away, which settles the count. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_EQ(door.light_toggles_in_flight_, 0); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); // the release, with nothing left to wait for + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// adopted back to what is known rather than turned into a command. +TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { + LightFixture fixture{light::LIGHT_ALWAYS_ON}; + connect_controller(fixture.door); + fixture.settle(); + + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press before the toggle is fetched cancels it, so the lamp never moves. +TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); + + // Nothing is left for the controller to fetch, so the lamp stays off as asked. + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even +// been shown has to keep counting. +TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + // The toggle still in the slot will invert what the door just reported. + EXPECT_FALSE(door.is_light_heading_on()); +} + +// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding, +// so the wait has to end rather than time out against toggles the door was never shown. +TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, so the wait for a lamp report starts + ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown + ASSERT_NE(door.light_toggle_released_at_, 0u); + + // The door reports the lamp change the first toggle caused, leaving only the unsent one. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + ASSERT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is +// going to fire, so it keeps counting. +TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) { + TestableHoermannHcp door; + // Wide enough that the toggle queued after the sleep cannot expire before update() runs. + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, which then says nothing about the lamp + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // Queued just now, so only the wait for the first toggle is overdue. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + door.update(); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_TRUE(door.is_light_toggle_pending_()); + EXPECT_TRUE(door.is_light_heading_on()); +} + +// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away. +TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + + // The door takes every key press but never reports the lamp, so nothing is ever confirmed. + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + } + + EXPECT_FALSE(door.toggle_light()); + EXPECT_EQ(door.light_toggles_in_flight_, 4); +} + +// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag +// itself rather than command against what was read before. +TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000})); + + EXPECT_FALSE(fixture.door.is_light_known()); + EXPECT_TRUE(fixture.output.status_has_warning()); +} + +// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that +// gap, the write still carries the old value and must not be taken for a request to invert the lamp. +TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) { + LightFixture fixture; + fixture.bring_up(); + // A door command holds the only command slot, so the request below is refused and the lamp published back. + ASSERT_TRUE(fixture.door.open_door()); + + auto call = fixture.state.make_call(); + call.set_state(true); + call.perform(); + fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass + + // The slot frees up and the lamp is switched on at the door before that publish arrives. + consume_command(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + fixture.settle(); + + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_TRUE(fixture.entity_on()); +} + +} // namespace esphome::hoermann_hcp::testing From 58a42fe5c27bdb2158d8c444d544f473fd0e3b0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 01:44:35 -0500 Subject: [PATCH 128/597] [core] Batch remote file downloads during config validation (#18069) --- esphome/components/animation/__init__.py | 5 + esphome/components/animation/image.py | 5 + esphome/components/bme68x_bsec2/__init__.py | 64 +++- .../components/bme68x_bsec2_i2c/__init__.py | 7 +- esphome/components/esp32/__init__.py | 50 ++- esphome/components/file/image.py | 81 ++-- esphome/components/font/__init__.py | 246 +++++++++--- esphome/components/gsl3670/touchscreen.py | 22 +- .../components/micro_wake_word/__init__.py | 16 +- esphome/components/shelly_dimmer/light.py | 108 ++++-- esphome/config.py | 128 ++++++- esphome/external_files.py | 224 +++++++++-- esphome/loader.py | 18 +- tests/component_tests/gsl3670/test_init.py | 22 +- .../components/bme68x_bsec2/__init__.py | 0 .../components/bme68x_bsec2/test_init.py | 64 ++++ tests/unit_tests/components/file/__init__.py | 0 .../unit_tests/components/file/test_image.py | 75 ++++ tests/unit_tests/components/font/__init__.py | 0 tests/unit_tests/components/font/test_init.py | 229 +++++++++++ .../unit_tests/components/gsl3670/__init__.py | 0 .../components/gsl3670/test_touchscreen.py | 35 ++ .../components/micro_wake_word/test_init.py | 9 +- .../components/shelly_dimmer/__init__.py | 0 .../components/shelly_dimmer/test_light.py | 154 ++++++++ tests/unit_tests/test_config_prefetch.py | 355 ++++++++++++++++++ tests/unit_tests/test_external_files.py | 341 +++++++++++++++-- 27 files changed, 2005 insertions(+), 253 deletions(-) create mode 100644 tests/unit_tests/components/bme68x_bsec2/__init__.py create mode 100644 tests/unit_tests/components/bme68x_bsec2/test_init.py create mode 100644 tests/unit_tests/components/file/__init__.py create mode 100644 tests/unit_tests/components/file/test_image.py create mode 100644 tests/unit_tests/components/font/__init__.py create mode 100644 tests/unit_tests/components/font/test_init.py create mode 100644 tests/unit_tests/components/gsl3670/__init__.py create mode 100644 tests/unit_tests/components/gsl3670/test_touchscreen.py create mode 100644 tests/unit_tests/components/shelly_dimmer/__init__.py create mode 100644 tests/unit_tests/components/shelly_dimmer/test_light.py create mode 100644 tests/unit_tests/test_config_prefetch.py diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 0df7c56313..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b0..73d428bd20 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv @@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES AUTO_LOAD = ["file"] DEPENDENCIES = ["display"] diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 63f63c5da2..c12eb39d2d 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,6 +11,8 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -105,6 +102,42 @@ def download_bme68x_blob(config): return config +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + def validate_bme68x(config): if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..dacd4e32ad 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, @@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2e72c78974..ada6d25db5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3280,27 +3280,45 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - from esphome.happy_eyeballs import ensure_happy_eyeballs - - ensure_happy_eyeballs() - - try: - req = requests.get(path, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise EsphomeError( - f"Could not download extra build file {path}: {e}" - ) from e - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - CORE.relative_build_path(name).write_bytes(req.content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79..b54c3f2adf 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path @@ -43,15 +42,13 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, MockObjClass +from esphome.external_files import RemoteFile from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,16 +62,16 @@ MDI_SOURCES = { SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", } +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") -def compute_local_image_path(value) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def local_path(value): @@ -83,16 +80,20 @@ def local_path(value): def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) return str(path) -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + mdi_id + ".svg" + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) @@ -101,17 +102,53 @@ def download_image(value): return download_file(value, compute_local_image_path(value)) -def validate_file_shorthand(value): - value = cv.string_strict(value) +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) value = cv.file_(value) return local_path(value) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 5872b607f1..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,7 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -296,46 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" ) from e - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -346,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -358,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith(("http://", "https://")): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index fc0318f076..ccccf06d69 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All( ) +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def _config_schema(config): model_option = { cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 255923f878..092c4977ce 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema( def _compute_local_file_path(config: dict) -> Path: - url = config[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index cd6d858067..dd99fcbc90 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -2,9 +2,7 @@ import hashlib from pathlib import Path import re -import requests - -from esphome import pins +from esphome import external_files, pins import esphome.codegen as cg from esphome.components import light, sensor, uart from esphome.components.const import CONF_SHA256 @@ -28,8 +26,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.core import HexInt +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -76,46 +75,85 @@ def parse_firmware_version(value): return major, minor -def get_firmware(value): +def _firmware_cache_path(name: str) -> Path: + return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin" + + +def _firmware_path(url: str, sha: str | None) -> Path: + """Cache path for a firmware blob: sha-keyed when verifiable, else + URL-keyed. Shared by the validator and the prefetch hook.""" + return _firmware_cache_path( + sha.lower() if sha else external_files.url_cache_key(url) + ) + + +def get_firmware(value: ConfigType) -> list[HexInt] | None: if not value[CONF_UPDATE]: return None - def dl(url): - try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e - - h = hashlib.new("sha256") - h.update(req.content) - return req.content, h.hexdigest() - url = value[CONF_URL] - if CONF_SHA256 in value: # we have a hash, enable caching - path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin") - - if not path.is_file(): - firmware_data, dl_hash = dl(url) - - if dl_hash != value[CONF_SHA256]: - raise cv.Invalid( - f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}" - ) - - path.parent.mkdir(exist_ok=True, parents=True) - path.write_bytes(firmware_data) - - else: + if expected := value.get(CONF_SHA256): + expected = expected.lower() + path = _firmware_path(url, expected) + if path.is_file(): firmware_data = path.read_bytes() - else: # no caching, download every time - firmware_data, dl_hash = dl(url) + if hashlib.sha256(firmware_data).hexdigest() == expected: + return [HexInt(x) for x in firmware_data] + # A corrupted or foreign cache entry must never be trusted just + # because the file exists; discard it and download again. + path.unlink() + firmware_data = external_files.download_content(url, path) + if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected: + path.unlink(missing_ok=True) + raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}") + else: + # No hash to verify the bytes, so an unrevalidated copy is an + # error rather than a silent fallback. + firmware_data = external_files.download_content( + url, + _firmware_path(url, None), + allow_stale=False, + ) return [HexInt(x) for x in firmware_data] +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if not isinstance(firmware, dict): + return None + try: + # cv.boolean, not truthiness: `update: "false"` is a valid False. + if not cv.boolean(firmware.get(CONF_UPDATE, False)): + return None + except cv.Invalid: + return None + url = firmware.get(CONF_URL) + sha = firmware.get(CONF_SHA256) + if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))): + url, sha = known + if not isinstance(url, str): + return None + if sha is not None: + # Reject anything but a well-formed hash; a raw string would + # otherwise become a path component before validation runs. + try: + sha = validate_sha256(sha) + except (cv.Invalid, ValueError, TypeError): + return None + path = _firmware_path(url, sha) + if sha is not None and path.is_file(): + # Content-addressed and already on disk; get_firmware verifies it + # by hash, so there is nothing to revalidate. + return None + # No hash means no stale copies, matching the validator's policy. + return RemoteFile(url, path, allow_stale=sha is not None) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def validate_firmware(value): config = value.copy() if CONF_URL not in config: diff --git a/esphome/config.py b/esphome/config.py index b747c69b3a..987bb9c96a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,14 +1,15 @@ from __future__ import annotations import abc -from contextlib import contextmanager +from collections.abc import Iterator +from contextlib import contextmanager, suppress import contextvars import copy import functools import heapq import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret +if TYPE_CHECKING: + from esphome.external_files import RemoteFile + _LOGGER = logging.getLogger(__name__) @@ -717,6 +721,125 @@ class AutoLoadValidationStep(ConfigValidationStep): ) +# Backstop against a runaway PREFETCH_FILES generator; no real component +# needs anywhere near this many stages (font, the deepest, uses two). +_MAX_PREFETCH_STAGES = 10 + + +class PrefetchRemoteFilesValidationStep(ConfigValidationStep): + """Batch-download remote files referenced by the raw config. + + Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see + ``ComponentManifest.prefetch_files``) download in one parallel pass, so + per-entry schema validators find a warm cache. Must run between + AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0): + metadata steps push priority-0 schema steps that pop immediately, so + this is the last point where every raw entry list is intact. Best + effort: failures are logged and memoized per run; the per-entry + validators stay authoritative. + """ + + priority = -1.5 + + def run(self, result: Config) -> None: + active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + + def warn_hook_failed(name: str, err: Exception) -> None: + # A broken hook must not fail validation; it only loses the + # batching speedup. + _LOGGER.warning("Remote file prefetch for %s failed: %s", name, err) + _LOGGER.debug("Prefetch hook traceback", exc_info=err) + + def start_hook( + name: str, manifest: ComponentManifest, entries: list[ConfigType] + ) -> None: + if (hook := manifest.prefetch_files) is None: + return + try: + active.append((name, iter(hook(entries)))) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + + for domain, conf in result.items(): + if not isinstance(domain, str) or domain.startswith("."): + continue + if (component := get_component(domain)) is None: + continue + if component.prefetch_files is None and not component.is_platform_component: + continue + if conf is None or isinstance(conf, core.AutoLoad): + continue + entries = [ + entry + for entry in (conf if isinstance(conf, list) else [conf]) + if isinstance(entry, dict) + ] + if not entries: + continue + # A domain-level hook on a platform component receives every + # entry; overlap with per-platform hooks dedupes by path. + start_hook(domain, component, entries) + if not component.is_platform_component: + continue + by_platform: dict[str, list[ConfigType]] = {} + for entry in entries: + if isinstance(p_name := entry.get(CONF_PLATFORM), str): + by_platform.setdefault(p_name, []).append(entry) + for p_name, p_entries in by_platform.items(): + if (platform := get_platform(domain, p_name)) is not None: + start_hook(f"{domain}.{p_name}", platform, p_entries) + + # One stage per round; later stages can read what earlier ones + # fetched. + for _ in range(_MAX_PREFETCH_STAGES): + if not active: + break + items: list[RemoteFile] = [] + still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + for name, generator in active: + try: + batch = list(next(generator)) + except StopIteration: + continue + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + continue + items.extend(batch) + still_active.append((name, generator)) + active = still_active + self._download(items) + for name, generator in active: + # A tripped backstop means a broken hook. + _LOGGER.warning( + "Remote file prefetch for %s stopped after %d stages", + name, + _MAX_PREFETCH_STAGES, + ) + if (close := getattr(generator, "close", None)) is not None: + # close() runs hook code too; it must not fail validation. + with suppress(Exception): + close() + + @staticmethod + def _download(items: list[RemoteFile]) -> None: + if not items: + return + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when a config actually references remote files. + from esphome import external_files + + try: + external_files.download_content_many(items, description="remote file(s)") + except cv.Invalid as err: + # INFO: the trace if an extractor's cache path ever drifts from + # its validator's, hiding the memoized failure replay. + _LOGGER.info("Remote file prefetch download failed: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # The batch downloader itself broke; make it visible. + _LOGGER.warning("Remote file prefetch failed: %s", err) + _LOGGER.debug("Prefetch download traceback", exc_info=err) + + class MetadataValidationStep(ConfigValidationStep): """Validate component metadata @@ -1259,6 +1382,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) + result.add_validation_step(PrefetchRemoteFilesValidationStep()) result.add_validation_step(IDPassValidationStep()) result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) diff --git a/esphome/external_files.py b/esphome/external_files.py index 160a2b6c29..f30d429425 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,16 +1,16 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from concurrent.futures import ThreadPoolExecutor import contextlib +from dataclasses import dataclass, field from datetime import UTC, datetime +import hashlib import logging import os from pathlib import Path import time -import requests - import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds @@ -21,8 +21,54 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] +DOMAIN = "external_files" + NETWORK_TIMEOUT = 30 + +@dataclass(frozen=True, slots=True) +class RemoteFile: + """A remote file to prefetch, yielded in stages by ``PREFETCH_FILES`` + hooks. A dataclass rather than a tuple so fields can be added later.""" + + url: str + path: Path + # False when nothing downstream can verify the bytes; a copy that + # cannot be revalidated is then an error, not a silent fallback. + allow_stale: bool = True + + +@dataclass(frozen=True, slots=True) +class FailedDownload: + """What went wrong for a cache path this run, kept for fast replay.""" + + url: str + message: str + cause: BaseException + + +@dataclass +class ExternalFilesRunData: + """Per-run download state, cleared by ``CORE.reset()`` between runs.""" + + # Verified fresh this run; later touches skip even the conditional HEAD. + fresh_paths: set[Path] = field(default_factory=set) + # Served from disk without revalidation; strict callers reject these. + stale_paths: set[Path] = field(default_factory=set) + # Served under skip_external_update, deliberately unchecked; skips the + # network like fresh_paths but never counts as verified. + unchecked_paths: set[Path] = field(default_factory=set) + # Failed with no usable copy; later touches replay the error fast. + failed_paths: dict[Path, FailedDownload] = field(default_factory=dict) + + +def _run_data() -> ExternalFilesRunData: + if (data := CORE.data.get(DOMAIN)) is not None: + return data + # setdefault: first touch may race on download_content_many's workers. + return CORE.data.setdefault(DOMAIN, ExternalFilesRunData()) + + IF_MODIFIED_SINCE = "If-Modified-Since" IF_NONE_MATCH = "If-None-Match" ETAG = "ETag" @@ -93,6 +139,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + # Deferred so configs with no remote files skip the heavy import. + import requests + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -127,6 +176,9 @@ def has_remote_file_changed( ) if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) + # A confirmed 304 supersedes any earlier failed + # revalidation of this file. + _run_data().stale_paths.discard(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -136,6 +188,9 @@ def has_remote_file_changed( url, e, ) + # The copy is a fallback, not a verified 304; record that so + # callers that must not use unverified bytes can reject it. + _run_data().stale_paths.add(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) @@ -159,14 +214,81 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: +def url_cache_key(url: str) -> str: + """Short stable cache key for a URL.""" + return hashlib.sha256(url.encode()).hexdigest()[:8] + + +def compute_local_file_path(domain: str, url: str) -> Path: + """Cache path for a URL-keyed download under the domain's cache dir. + + Pure (no mkdir); parent directories are created at write time. + """ + return Path(CORE.data_dir) / domain / url_cache_key(url) + + +def is_fresh_this_run(path: Path) -> bool: + """Whether `path` was verified or downloaded during this run.""" + return path in _run_data().fresh_paths + + +def download_content( + url: str, + path: Path, + timeout: int = NETWORK_TIMEOUT, + allow_stale: bool = True, + return_content: bool = True, +) -> bytes: + """Download `url` into `path` and return the bytes, using the cache. + + On network failure an on-disk copy is served with a warning, unless + ``allow_stale=False``. ``CORE.skip_external_update`` always serves the + copy. ``return_content=False`` skips the disk read on cache hits. + """ + + # Deferred so configs with no remote files skip the heavy import. + import requests + + def _cached() -> bytes: + return path.read_bytes() if return_content else b"" + + # Memoized paths skip the network entirely; concurrent access is safe + # because download_content_many dedupes by path before fanning out. + run_data = _run_data() + fresh_paths = run_data.fresh_paths + if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists(): + return _cached() + if allow_stale and path in run_data.stale_paths and path.exists(): + # Strict callers fall through to try the network themselves. + _LOGGER.info("Using cached copy of %s that could not be revalidated", url) + return _cached() + if (failure := run_data.failed_paths.get(path)) is not None: + if not path.exists(): + if failure.url == url: + raise cv.Invalid(failure.message) from failure.cause + raise cv.Invalid( + f"Could not download from {url}: an earlier download of " + f"{failure.url} to the same cache file failed: {failure.cause}" + ) from failure.cause + # The file appeared since the failure; revalidate normally. + del run_data.failed_paths[path] ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) - return path.read_bytes() + run_data.unchecked_paths.add(path) + return _cached() if not has_remote_file_changed(url, path, timeout): + if path in run_data.stale_paths: + # The HEAD fell back to the copy without confirming it. + if not allow_stale: + raise cv.Invalid( + f"Could not check {url} for updates due to a network error " + f"and the cached copy cannot be verified" + ) + return _cached() _LOGGER.debug("Remote file has not changed %s", url) - return path.read_bytes() + fresh_paths.add(path) + return _cached() _LOGGER.info("Downloading %s", url) _LOGGER.debug("Saving to %s", path) @@ -185,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content except requests.exceptions.RequestException as e: if path.exists(): + # Memoized so a flaky host warns once per run, not per consumer. + run_data.stale_paths.add(path) + if not allow_stale: + raise cv.Invalid(f"Could not download from {url}: {e}") from e _LOGGER.warning( "Could not download from %s due to network error (%s), using cached file", url, e, ) - return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") from e + return _cached() + message = f"Could not download from {url}: {e}" + run_data.failed_paths[path] = FailedDownload(url, message, e) + raise cv.Invalid(message) from e write_file(path, data) _write_etag(path, req.headers.get(ETAG)) + fresh_paths.add(path) + run_data.stale_paths.discard(path) return data @@ -207,50 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( - items: Iterable[tuple[str, Path]], + items: Iterable[RemoteFile], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, description: str = "remote file(s)", ) -> None: - """Run `download_content` for each (url, path) pair concurrently. + """Run `download_content` for each `RemoteFile` concurrently. - `description` names the kind of files in the progress log line, e.g. - "wake word manifest(s)". - - Wall time drops from `sum(latency)` to roughly `max(latency)` for cached - files where the HEAD round-trip dominates. All workers run to - completion before this returns; every `cv.Invalid` raised by a worker - is collected and surfaced together as `cv.MultipleInvalid` so the user - sees every broken file in a single validation pass instead of fixing - them one round-trip at a time. - - Items are de-duplicated by `path` -- two callers asking for the same - cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. When the - same `path` appears more than once, the last URL wins (standard dict - comprehension semantics); in practice duplicate paths only arise when - the URL is duplicated, so the choice doesn't matter. + `description` names the files in the progress log line. All workers run + to completion; every `cv.Invalid` raised is surfaced together as + `cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on + the same cache file); the last URL wins and a strict + `allow_stale=False` from any duplicate is kept. """ - seen: dict[Path, str] = {path: url for url, path in items} - if not seen: + seen: dict[Path, RemoteFile] = {} + for file in items: + if (prior := seen.get(file.path)) is not None and not prior.allow_stale: + file = RemoteFile(file.url, file.path, allow_stale=False) + seen[file.path] = file + unique = list(seen.values()) + if not unique: return ensure_happy_eyeballs() - _LOGGER.info("Checking %d %s for updates", len(seen), description) - if len(seen) == 1: - path, url = next(iter(seen.items())) - download_content(url, path, timeout) + _LOGGER.info("Checking %d %s for updates", len(unique), description) + + def _download_one(file: RemoteFile) -> None: + download_content( + file.url, + file.path, + timeout, + allow_stale=file.allow_stale, + return_content=False, + ) + + if len(unique) == 1: + _download_one(unique[0]) return - def _download_one(path_url: tuple[Path, str]) -> None: - # `seen` stores entries as (path, url) so the dict can dedupe by - # path; flip them back to download_content's (url, path) order. - path, url = path_url - download_content(url, path, timeout) - - workers = max(1, min(max_workers, len(seen))) + workers = max(1, min(max_workers, len(unique))) errors: list[cv.Invalid] = [] with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(_download_one, item) for item in seen.items()] + futures = [ex.submit(_download_one, file) for file in unique] for future in futures: try: future.result() @@ -263,6 +390,21 @@ def download_content_many( raise cv.MultipleInvalid(errors) +def single_stage_prefetch( + extract: Callable[[ConfigType], RemoteFile | None], +) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]: + """Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor. + + Covers the common case of one remote file per raw config entry; + components with staged downloads write their own generator. + """ + + def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]: + yield [ref for entry in entries if (ref := extract(entry)) is not None] + + return prefetch_files + + # Each component that uses external_files defines its own local # `TYPE_WEB = "web"`; the string is repeated here rather than imported # because there is no canonical `TYPE_WEB` in `esphome.const` to share. @@ -282,7 +424,7 @@ def download_web_files_in_config( slotted directly into a `cv.All(...)` chain. """ download_content_many( - (conf_file[CONF_URL], path_for(conf_file)) + RemoteFile(conf_file[CONF_URL], path_for(conf_file)) for entry in config if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE ) diff --git a/esphome/loader.py b/esphome/loader.py index 22db8b156a..7a659aa0a8 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -16,6 +16,7 @@ from esphome.types import ConfigType if TYPE_CHECKING: from esphome.cpp_generator import MockObjClass + from esphome.external_files import RemoteFile # `esphome.core.config` is imported lazily in `_lookup_module` when the # "esphome" pseudo-component is first resolved. It pulls in @@ -135,6 +136,21 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def prefetch_files( + self, + ) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None: + """Optional `PREFETCH_FILES` hook for batched remote file downloads. + + A generator called once per run with the component's raw, pre-schema + config entries; each yield is a stage of ``RemoteFile`` downloaded in + one parallel pass before schema validation, so a later stage may + derive URLs from earlier files' content. Best effort: skip anything + unrecognized. On platform components, place it on the platform + sub-module; a domain-module hook receives every entry. + """ + return getattr(self.module, "PREFETCH_FILES", None) + @property def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 8528cf23ca..950fa389be 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The cache path is derived from (and stable for) the URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) first = gsl._cache_path(VALID_URL) assert first == gsl._cache_path(VALID_URL) assert first != gsl._cache_path("https://example.com/other.bin") - assert first.parent == tmp_path + assert first.parent == tmp_path / "gsl3670" def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: @@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """A ``url`` source resolves to the cache path for that URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) @@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates( ) -> None: """A url source downloads the content and validates its structure.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} @@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected( ) -> None: """A configured SHA-256 that does not match the download is rejected.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) @@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Downloaded content that is not a valid blob is rejected.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr( gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" ) diff --git a/tests/unit_tests/components/bme68x_bsec2/__init__.py b/tests/unit_tests/components/bme68x_bsec2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/bme68x_bsec2/test_init.py b/tests/unit_tests/components/bme68x_bsec2/test_init.py new file mode 100644 index 0000000000..b34231a1aa --- /dev/null +++ b/tests/unit_tests/components/bme68x_bsec2/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the bme68x_bsec2 prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components import bme68x_bsec2 as bsec +from esphome.loader import get_component + + +def test_prefetch_applies_defaults(setup_core: Path) -> None: + [files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}])) + assert len(files) == 1 + assert "bme680_iaq_33v_3s_28d" in files[0].url + assert files[0].path == bsec._compute_local_file_path(files[0].url) + + +def test_prefetch_normalizes_enum_case(setup_core: Path) -> None: + [files] = list( + bsec.PREFETCH_FILES( + [ + { + "model": "BME688", + "sample_rate": "ulp", + "supply_voltage": "1.8v", + "algorithm_output": "REGRESSION", + "operating_age": "4D", + } + ] + ) + ) + assert len(files) == 1 + assert "bme688_reg_18v_300s_4d" in files[0].url + + +def test_prefetch_skips_unknown_values(setup_core: Path) -> None: + entries = [ + {"model": "bme999"}, + {"model": "bme680", "sample_rate": "TURBO"}, + {"model": "bme680", "algorithm_output": "psychic"}, + {}, + ] + assert list(bsec.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_matches_validator_url(setup_core: Path) -> None: + """The hook's URL equals _compute_url over the validated config shape.""" + validated = { + "model": "bme688", + "operating_age": "28d", + "sample_rate": "LP", + "supply_voltage": "3.3V", + "algorithm_output": "classification", + } + [files] = list(bsec.PREFETCH_FILES([dict(validated)])) + assert files[0].url == bsec._compute_url(validated) + + +def test_hook_is_wired_to_the_user_facing_domain() -> None: + """The i2c domain (the only user-facing one) exposes the hook.""" + + component = get_component("bme68x_bsec2_i2c") + assert component is not None + assert component.prefetch_files is bsec.PREFETCH_FILES diff --git a/tests/unit_tests/components/file/__init__.py b/tests/unit_tests/components/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py new file mode 100644 index 0000000000..a9c1684db3 --- /dev/null +++ b/tests/unit_tests/components/file/test_image.py @@ -0,0 +1,75 @@ +"""Tests for the file image platform's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from esphome.components.file import image as file_image +from esphome.external_files import RemoteFile +from esphome.loader import get_component, get_platform + + +def test_extract_mdi_shorthand(setup_core: Path) -> None: + ref = file_image._extract_file_ref("mdi:home") + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg" + assert ref.path.name == "home.svg" + assert ref.path.parent.name == "mdi" + + +def test_extract_web_url(setup_core: Path) -> None: + url = "https://example.com/img.png" + ref = file_image._extract_file_ref(url) + assert ref == RemoteFile(url, file_image.compute_local_image_path(url)) + + +def test_extract_typed_dicts(setup_core: Path) -> None: + url = "https://example.com/img.png" + assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile( + url, file_image.compute_local_image_path(url) + ) + ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"}) + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg" + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert file_image._extract_file_ref("images/local.png") is None + assert file_image._extract_file_ref("mdi:not a valid icon!") is None + assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None + assert file_image._extract_file_ref(42) is None + assert file_image._extract_file_ref(None) is None + + +def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: + entries = [ + {"file": "mdi:home"}, + {"file": "images/local.png"}, + {"file": "https://example.com/img.png"}, + {"no_file_key": True}, + ] + [files] = list(file_image.PREFETCH_FILES(entries)) + assert len(files) == 2 + assert files[0].url.endswith("home.svg") + assert files[1].url == "https://example.com/img.png" + + +def test_extractor_matches_validator_path(setup_core: Path) -> None: + """The path the validator downloads to equals the extractor's path.""" + with patch( + "esphome.components.file.image.external_files.download_content" + ) as mock_download: + file_image.validate_file_shorthand("mdi:home") + + validated_path = mock_download.call_args[0][1] + assert validated_path == file_image._extract_file_ref("mdi:home").path + + +def test_hook_is_wired_to_both_animation_domains() -> None: + """Both animation entry points expose the shared image hook.""" + + assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES + assert ( + get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES + ) diff --git a/tests/unit_tests/components/font/__init__.py b/tests/unit_tests/components/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/font/test_init.py b/tests/unit_tests/components/font/test_init.py new file mode 100644 index 0000000000..0ea3a0e3a1 --- /dev/null +++ b/tests/unit_tests/components/font/test_init.py @@ -0,0 +1,229 @@ +"""Tests for the font component's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import external_files +from esphome.components import font +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict: + return {"family": family, "weight": weight, "italic": italic} + + +def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None: + spec = font._extract_remote_font("gfonts://Roboto") + assert spec is not None + assert spec[font.CONF_FAMILY] == "Roboto" + assert spec[font.CONF_WEIGHT] == 400 + assert spec[font.CONF_ITALIC] is False + + +def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None: + assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700 + assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500 + + +def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None: + """Boolean spellings the schema accepts are accepted by the extractor.""" + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "true"} + ) + assert spec is not None + assert spec[font.CONF_ITALIC] is True + assert ( + font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "maybe"} + ) + is None + ) + + +def test_extract_typed_gfonts_dict(setup_core: Path) -> None: + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True} + ) + assert spec is not None + assert spec[font.CONF_WEIGHT] == 500 + assert spec[font.CONF_ITALIC] is True + + +def test_extract_web_font(setup_core: Path) -> None: + url = "https://example.com/font.ttf" + for value in (url, {"type": "web", "url": url}): + spec = font._extract_remote_font(value) + assert spec is not None + assert spec[font.CONF_URL] == url + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert font._extract_remote_font("fonts/local.ttf") is None + assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None + assert ( + font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"}) + is None + ) + assert font._extract_remote_font(42) is None + + +def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None: + entries = [ + {"file": "gfonts://Roboto"}, + {"file": "fonts/local.ttf"}, + { + "file": "https://example.com/font.ttf", + "extras": [{"file": "gfonts://Monocraft"}], + }, + ] + batches = list(font.PREFETCH_FILES(entries)) + urls = [file.url for file in batches[0]] + assert font._gfonts_css_url(_gspec("Roboto")) in urls + assert font._gfonts_css_url(_gspec("Monocraft")) in urls + assert "https://example.com/font.ttf" in urls + assert len(batches[0]) == 3 + + +def test_prefetch_skips_recent_ttf(setup_core: Path) -> None: + path = font._gfonts_ttf_path(_gspec("Roboto")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached ttf") + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches == [[], []] + + +def test_stage2_parses_cached_css(setup_core: Path) -> None: + + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');" + ) + # Stage two only trusts CSS confirmed fetched this run. + external_files._run_data().fresh_paths.add(css_path) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [ + RemoteFile( + "https://fonts.gstatic.com/roboto.ttf", + font._gfonts_ttf_path(_gspec("Roboto")), + ) + ] + + +def test_stage2_skips_missing_css(setup_core: Path) -> None: + batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}])) + assert batches[1] == [] + + +def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None: + """A bare-mapping extras value (valid raw config) is scanned.""" + entries = [ + { + "file": "fonts/local.ttf", + "extras": {"file": "gfonts://Roboto", "glyphs": "ABC"}, + } + ] + batches = list(font.PREFETCH_FILES(entries)) + assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))] + + +def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None: + """A CSS body that fails to parse is removed from the cache.""" + + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + css_path = font._gfonts_css_path(spec) + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"no truetype url here", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="please report this"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"\xff\xfe\x00\x01binary", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="not a text document"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + +def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None: + """A CSS body that could not be revalidated is not parsed for a ttf + URL; the cached font is used instead.""" + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + ttf_path = font._gfonts_ttf_path(spec) + ttf_path.parent.mkdir(parents=True, exist_ok=True) + ttf_path.write_bytes(b"cached ttf") + cache = MagicMock() + with ( + patch.object(font, "FONT_CACHE", cache), + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + ): + assert font.download_gfont(spec) is spec + cache.__setitem__.assert_called_once_with(spec, ttf_path) + + +def test_unrevalidated_gfonts_css_without_cached_font_errors( + setup_core: Path, +) -> None: + """No verified CSS and no cached font is a clear error.""" + spec = { + "family": "Roboto", + "weight": 500, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + pytest.raises(cv.Invalid, match="no cached font"), + ): + font.download_gfont(spec) + + +def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None: + """A leftover CSS from an earlier run is not trusted for stage two.""" + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');" + ) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [] diff --git a/tests/unit_tests/components/gsl3670/__init__.py b/tests/unit_tests/components/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/gsl3670/test_touchscreen.py b/tests/unit_tests/components/gsl3670/test_touchscreen.py new file mode 100644 index 0000000000..a4b96d72da --- /dev/null +++ b/tests/unit_tests/components/gsl3670/test_touchscreen.py @@ -0,0 +1,35 @@ +"""Tests for the gsl3670 touchscreen prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.external_files import RemoteFile + + +def test_prefetch_explicit_url(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"platform": "gsl3670", "firmware": {"url": url}}] + assert list(gsl.PREFETCH_FILES(entries)) == [ + [RemoteFile(url, gsl._cache_path(url))] + ] + + +def test_prefetch_model_default_firmware(setup_core: Path) -> None: + entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}] + [files] = list(gsl.PREFETCH_FILES(entries)) + assert len(files) == 1 + assert ( + files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"] + ) + assert files[0].path == gsl._cache_path(files[0].url) + + +def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None: + entries = [ + {"platform": "gsl3670", "firmware": {"file": "fw.bin"}}, + {"platform": "gsl3670", "model": "CUSTOM"}, + {"platform": "gsl3670"}, + ] + assert list(gsl.PREFETCH_FILES(entries)) == [[]] diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py index 84371ab906..96fb73b18b 100644 --- a/tests/unit_tests/components/micro_wake_word/test_init.py +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) +from esphome.external_files import RemoteFile @pytest.fixture @@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models( assert mock_download_content_many.call_count == 2 manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) assert manifest_items == [ - (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + RemoteFile( + f"https://example.com/models/{name}.json", paths[name] / "manifest.json" + ) for name in names ] model_items = list(mock_download_content_many.call_args_list[1].args[0]) assert model_items == [ - (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + RemoteFile( + f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite" + ) for name in names ] diff --git a/tests/unit_tests/components/shelly_dimmer/__init__.py b/tests/unit_tests/components/shelly_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/shelly_dimmer/test_light.py b/tests/unit_tests/components/shelly_dimmer/test_light.py new file mode 100644 index 0000000000..e5440db4c9 --- /dev/null +++ b/tests/unit_tests/components/shelly_dimmer/test_light.py @@ -0,0 +1,154 @@ +"""Tests for the shelly_dimmer firmware download and prefetch extraction.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome import external_files +from esphome.components.shelly_dimmer import light as shd +from esphome.config_validation import Invalid +from esphome.external_files import RemoteFile + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_prefetch_known_version(setup_core: Path) -> None: + entries = [{"firmware": {"version": "51.6", "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + url, sha = shd.KNOWN_FIRMWARE["51.6"] + assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]] + + +def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None: + """Quoted booleans behave as the schema will normalize them.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + off = [{"firmware": {"version": "51.6", "update": "false"}}] + assert list(shd.PREFETCH_FILES(off)) == [[]] + on = [{"firmware": {"version": "51.6", "update": "true"}}] + assert list(shd.PREFETCH_FILES(on)) == [ + [RemoteFile(url, shd._firmware_cache_path(sha))] + ] + + +def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None: + """A raw sha256 that is not a hash never becomes a path component.""" + entries = [ + { + "firmware": { + "url": "https://example.com/fw.bin", + "sha256": "/tmp/payload", + "update": True, + } + } + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None: + """A sha-keyed cache file needs no revalidation; get_firmware hashes it.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + shd._firmware_cache_path(sha).write_bytes(b"pinned firmware") + entries = [{"firmware": {"version": "51.6", "update": True}}] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"firmware": {"url": url, "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + key = external_files.url_cache_key(url) + # No sha means the bytes cannot be verified, so the prefetch itself + # must carry the validator's strict no-stale policy. + assert stages == [ + [RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)] + ] + + +def test_prefetch_skips_no_update(setup_core: Path) -> None: + entries = [ + {"firmware": {"version": "51.6"}}, + {"firmware": "51.6"}, + {"firmware": {"version": "0.0", "update": True}}, + {}, + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None: + """A cached blob failing its hash check is discarded and re-downloaded.""" + good = b"good firmware" + expected = _sha(good) + path = shd._firmware_cache_path(expected) + path.write_bytes(b"corrupted blob") + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=good, + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_called_once() + assert result == [int(b) for b in good] + + +def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None: + """A cached blob passing its hash check is used with zero network.""" + good = b"good firmware" + expected = _sha(good) + shd._firmware_cache_path(expected).write_bytes(good) + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content" + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_not_called() + assert result == [int(b) for b in good] + + +def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None: + """A fresh download failing its hash check raises and is not cached.""" + expected = _sha(b"expected firmware") + path = shd._firmware_cache_path(expected) + + with ( + patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"wrong firmware", + ), + pytest.raises(Invalid, match="Hash mismatch"), + ): + shd.get_firmware( + {"update": True, "url": "https://example.com/fw.bin", "sha256": expected} + ) + + assert not path.exists() + + +def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None: + """The unverifiable no-hash branch must not accept a stale copy.""" + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"fw", + ) as mock_download: + shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"}) + + assert mock_download.call_args.kwargs["allow_stale"] is False diff --git a/tests/unit_tests/test_config_prefetch.py b/tests/unit_tests/test_config_prefetch.py new file mode 100644 index 0000000000..afb93a09a0 --- /dev/null +++ b/tests/unit_tests/test_config_prefetch.py @@ -0,0 +1,355 @@ +"""Tests for the remote file prefetch validation step.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import core +from esphome.config import Config, PrefetchRemoteFilesValidationStep +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace: + return SimpleNamespace( + is_platform_component=is_platform, + prefetch_files=prefetch, + ) + + +def _run_step( + domains: dict[str, Any], + components: dict[str, Any], + platforms: dict[tuple[str, str], Any] | None = None, + download_side_effect: Any = None, +) -> tuple[Config, MagicMock]: + result = Config() + for domain, conf in domains.items(): + result[domain] = conf + with ( + patch("esphome.config.get_component", side_effect=components.get), + patch( + "esphome.config.get_platform", + side_effect=lambda d, p: (platforms or {}).get((d, p)), + ), + patch( + "esphome.external_files.download_content_many", + side_effect=download_side_effect, + ) as mock_download, + ): + PrefetchRemoteFilesValidationStep().run(result) + return result, mock_download + + +def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]: + return list(mock_download.call_args_list[call][0][0]) + + +def test_component_hook_receives_normalized_entries() -> None: + """A bare dict conf is passed to the hook as a one-entry list.""" + seen: list[Any] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("https://example.com/a", Path("/cache/a"))] + + _, mock_download = _run_step( + {"my_comp": {"key": "value"}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert seen == [[{"key": "value"}]] + mock_download.assert_called_once() + assert _downloaded(mock_download) == [ + RemoteFile("https://example.com/a", Path("/cache/a")) + ] + + +def test_platform_entries_are_grouped_per_platform() -> None: + """Platform domains route entries to each platform module's hook.""" + seen_a: list[Any] = [] + seen_b: list[Any] = [] + + def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_a.extend(entries) + yield [RemoteFile("url-a", Path("/a"))] + + def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_b.extend(entries) + yield [RemoteFile("url-b", Path("/b"))] + + entries = [ + {"platform": "a", "n": 1}, + {"platform": "b", "n": 2}, + {"platform": "a", "n": 3}, + ] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(is_platform=True)}, + platforms={ + ("image", "a"): _component(prefetch=hook_a), + ("image", "b"): _component(prefetch=hook_b), + }, + ) + + assert seen_a == [entries[0], entries[2]] + assert seen_b == [entries[1]] + assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [ + RemoteFile("url-a", Path("/a")), + RemoteFile("url-b", Path("/b")), + ] + + +def test_hook_failure_does_not_fail_validation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising hook is logged and other hooks still prefetch.""" + + def bad_hook(entries: list[dict]) -> list[RemoteFile]: + raise RuntimeError("garbage config") + + def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/g"))] + + _, mock_download = _run_step( + {"bad": {"x": 1}, "good": {"y": 2}}, + { + "bad": _component(prefetch=bad_hook), + "good": _component(prefetch=good_hook), + }, + ) + + assert "Remote file prefetch for bad failed" in caplog.text + assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))] + + +def test_stages_download_between_resumptions() -> None: + """Each yielded stage is downloaded before the generator resumes.""" + order: list[str] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + order.append("stage1") + yield [RemoteFile("css-url", Path("/css"))] + order.append("stage2") + yield [RemoteFile("ttf-url", Path("/ttf"))] + + def record_download(items: Any, description: str) -> None: + order.append(f"download:{[file.url for file in items]}") + + _, mock_download = _run_step( + {"font": {"f": 1}}, + {"font": _component(prefetch=hook)}, + download_side_effect=record_download, + ) + + assert order == [ + "stage1", + "download:['css-url']", + "stage2", + "download:['ttf-url']", + ] + assert mock_download.call_count == 2 + + +def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None: + """An endless generator stops after the stage backstop.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + n = 0 + while True: + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + n += 1 + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_mid_stage_failure_stops_only_that_hook( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator raising on a later stage does not affect other hooks.""" + + def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("first", Path("/first"))] + raise RuntimeError("stage two exploded") + + def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("one", Path("/one"))] + yield [RemoteFile("two", Path("/two"))] + + _, mock_download = _run_step( + {"flaky": {"x": 1}, "steady": {"y": 2}}, + { + "flaky": _component(prefetch=flaky_hook), + "steady": _component(prefetch=steady_hook), + }, + ) + + assert "Remote file prefetch for flaky failed" in caplog.text + assert mock_download.call_count == 2 + assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))] + + +def test_download_failure_is_swallowed() -> None: + """cv.Invalid from the batch download never escapes the step.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=cv.Invalid("download failed"), + ) + + mock_download.assert_called_once() + assert not result.errors + + +def test_domains_without_hooks_do_not_download() -> None: + """Components without PREFETCH_FILES cause no download call.""" + _, mock_download = _run_step( + {"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}}, + {"plain": _component()}, + ) + mock_download.assert_not_called() + + +def test_none_and_autoload_confs_are_skipped() -> None: + """None and AutoLoad confs never reach a hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"a": None, "b": core.AutoLoad()}, + {"a": _component(prefetch=hook), "b": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_non_dict_entries_are_ignored() -> None: + """Garbage entries never reach a component hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"my_comp": ["just-a-string", 42]}, + {"my_comp": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_platform_entries_without_platform_key_are_ignored() -> None: + """Entries with a missing or unknown platform never reach a hook.""" + _, mock_download = _run_step( + {"image": [{"n": 1}, "garbage", {"platform": "unknown"}]}, + {"image": _component(is_platform=True)}, + ) + mock_download.assert_not_called() + + +def test_generator_still_alive_at_the_cap_is_warned_and_closed( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator with a stage left at the cap is warned about and closed.""" + closed: list[bool] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + finally: + closed.append(True) + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + assert closed == [True] + + +def test_plain_iterable_hook_survives_the_cap( + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook returning a plain list of batches cannot crash the backstop.""" + + def hook(entries: list[dict]) -> list[list[RemoteFile]]: + return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)] + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_domain_level_hook_on_platform_component() -> None: + """A hook on the platform component's domain module sees all entries.""" + seen: list[Any] = [] + + def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("domain-url", Path("/domain"))] + + entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(prefetch=domain_hook, is_platform=True)}, + ) + + assert seen == [entries] + assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))] + + +def test_generator_raising_on_close_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator whose close() raises at the cap is logged, not crashed on.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + except GeneratorExit: + raise RuntimeError("close exploded") from None + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_unexpected_download_error_is_logged_visibly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken batch downloader warns instead of silently disabling prefetch.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=TypeError("not a RemoteFile"), + ) + + mock_download.assert_called_once() + assert not result.errors + assert "Remote file prefetch failed" in caplog.text diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 16cee9564f..4e993ff4f3 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -3,6 +3,7 @@ import os from pathlib import Path import time +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: @pytest.fixture def mock_requests_head() -> MagicMock: - """Patch `external_files.requests.head` so the conditional HEAD-request - validator can be tested without doing real HTTP. + """Patch `requests.head` so the conditional HEAD-request validator can + be tested without doing real HTTP. Patched on the requests module + because external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.head") as m: + with patch("requests.head") as m: yield m @pytest.fixture def mock_requests_get() -> MagicMock: - """Patch `external_files.requests.get` so the download path can be - tested without doing real HTTP. + """Patch `requests.get` so the download path can be tested without + doing real HTTP. Patched on the requests module because + external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.get") as m: + with patch("requests.get") as m: yield m @@ -549,6 +552,10 @@ def test_download_content_skip_external_update_uses_cache( assert result == cached_content mock_has_remote_file_changed.assert_not_called() mock_requests_get.assert_not_called() + # Deliberately unchecked is memoized for the run but never "fresh". + assert not external_files.is_fresh_this_run(test_file) + assert external_files.download_content(url, test_file) == cached_content + mock_has_remote_file_changed.assert_not_called() def test_download_content_skip_external_update_downloads_when_missing( @@ -587,10 +594,16 @@ def test_download_content_many_single_item_avoids_pool( mock_download_content: MagicMock, setup_core: Path ) -> None: """A single item should be downloaded inline (no thread pool overhead).""" - item = ("https://example.com/file.txt", setup_core / "f.txt") + item = external_files.RemoteFile( + "https://example.com/file.txt", setup_core / "f.txt" + ) external_files.download_content_many([item]) mock_download_content.assert_called_once_with( - item[0], item[1], external_files.NETWORK_TIMEOUT + item.url, + item.path, + external_files.NETWORK_TIMEOUT, + allow_stale=True, + return_content=False, ) @@ -602,7 +615,12 @@ def test_download_content_many_runs_in_parallel( barrier = threading.Barrier(3) - def slow_download(url: str, path: Path, timeout: int) -> bytes: + def slow_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: # If calls were serial this would deadlock (third caller never arrives # while the first is blocked at the barrier). barrier.wait(timeout=2.0) @@ -610,9 +628,9 @@ def test_download_content_many_runs_in_parallel( mock_download_content.side_effect = slow_download items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] external_files.download_content_many(items, max_workers=4) assert mock_download_content.call_count == 3 @@ -625,15 +643,20 @@ def test_download_content_many_propagates_single_error( it in a `MultipleInvalid` that the caller would have to unpack. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("bad"): raise Invalid(f"could not download {url}") return b"" mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad", setup_core / "bad"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad", setup_core / "bad"), ] with pytest.raises(Invalid, match="could not download") as exc_info: external_files.download_content_many(items) @@ -648,16 +671,21 @@ def test_download_content_many_aggregates_multiple_errors( them one network round-trip at a time. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("ok"): return b"" raise Invalid(f"could not download {url}") mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad1", setup_core / "bad1"), - ("https://example.com/bad2", setup_core / "bad2"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"), + external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"), ] with pytest.raises(MultipleInvalid) as exc_info: external_files.download_content_many(items) @@ -678,9 +706,9 @@ def test_download_content_many_dedupes_by_path( """ path = setup_core / "shared" items = [ - ("https://example.com/a", path), - ("https://example.com/b", path), - ("https://example.com/a", path), + external_files.RemoteFile("https://example.com/a", path), + external_files.RemoteFile("https://example.com/b", path), + external_files.RemoteFile("https://example.com/a", path), ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 @@ -695,8 +723,8 @@ def test_download_content_many_clamps_invalid_max_workers( be clamped up to at least 1 worker. """ items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) assert mock_download_content.call_count == 2 @@ -724,8 +752,8 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_download_content_many.assert_called_once() assert list(mock_download_content_many.call_args[0][0]) == [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] @@ -799,3 +827,264 @@ def test_download_content_atomic_write_no_partial_on_failure( # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] + + +def test_download_content_memoizes_fresh_path( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A path downloaded once this run skips all network on later calls.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"fresh content" + assert external_files.download_content(url, test_file) == b"fresh content" + + mock_has_remote_file_changed.assert_called_once() + mock_requests_get.assert_called_once() + + +def test_download_content_memo_revalidates_deleted_file( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A memoized path whose file vanished is downloaded again.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + test_file.unlink() + external_files.download_content(url, test_file) + + assert mock_requests_get.call_count == 2 + + +def test_download_content_failure_fails_fast_on_retry( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A failed download is remembered; a retry raises without network.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + + mock_requests_get.assert_called_once() + + +def test_download_content_failed_path_revalidates_when_file_appears( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A recorded failure is dropped once the file exists on disk.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid): + external_files.download_content(url, test_file) + + # Another writer produced the file; the cached failure no longer applies + # and the network error now falls back to the on-disk copy. + test_file.write_bytes(b"appeared") + assert external_files.download_content(url, test_file) == b"appeared" + + +def test_download_content_network_error_fallback_memoizes( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Falling back to a cached file memoizes, so a flaky host is hit once.""" + test_file = setup_core / "memo.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_called_once() + + +def test_download_content_not_changed_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A 304 not-changed check serves the cached file without a GET.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + mock_has_remote_file_changed.return_value = False + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_not_called() + + +def test_head_failure_fallback_is_stale_not_fresh( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A HEAD network failure serves the copy once and memoizes it as stale.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_head.assert_called_once() + mock_requests_get.assert_not_called() + + +def test_allow_stale_false_rejects_unverified_copy( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False raises instead of building from an unverified copy.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + + # A strict caller gets its own attempt at the network rather than + # inheriting the stale memo's verdict. + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + assert mock_requests_get.call_count == 2 + + # A caller that tolerates stale copies still gets the cached bytes. + assert external_files.download_content(url, test_file) == b"cached content" + + +def test_allow_stale_false_rejects_head_failure_fallback( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False also rejects a copy the HEAD could not confirm.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="cannot be verified"): + external_files.download_content(url, test_file, allow_stale=False) + mock_requests_get.assert_not_called() + + +def test_download_content_many_forwards_per_file_allow_stale( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Each RemoteFile's own allow_stale reaches download_content.""" + files = [ + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile( + "https://example.com/b", setup_core / "b", allow_stale=False + ), + ] + external_files.download_content_many(files) + forwarded = { + call.args[1]: call.kwargs["allow_stale"] + for call in mock_download_content.call_args_list + } + assert forwarded == {setup_core / "a": True, setup_core / "b": False} + + +def test_download_content_many_dedupe_keeps_strictest( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A strict duplicate wins over a permissive one for the same path.""" + path = setup_core / "fw.bin" + files = [ + external_files.RemoteFile("https://example.com/fw", path, allow_stale=False), + external_files.RemoteFile("https://example.com/fw", path), + ] + external_files.download_content_many(files) + mock_download_content.assert_called_once() + assert mock_download_content.call_args.kwargs["allow_stale"] is False + + +def test_successful_head_revalidation_clears_stale( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A confirmed 304 supersedes an earlier failed revalidation.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + ok_304 = MagicMock(status_code=304, headers={}) + mock_requests_head.side_effect = [ + requests.exceptions.RequestException("blip"), + ok_304, + ] + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + # The stale memo short-circuits tolerant callers; a strict caller + # triggers a fresh HEAD, which now succeeds and clears the marker. + assert ( + external_files.download_content(url, test_file, allow_stale=False) + == b"cached content" + ) + # Verified now: served from the fresh memo with no more network. + assert external_files.download_content(url, test_file) == b"cached content" + assert mock_requests_head.call_count == 2 + mock_requests_get.assert_not_called() + + +def test_failed_path_replay_names_the_other_url( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A shared cache path replays the failure naming the original URL.""" + test_file = setup_core / "shared.bin" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + with pytest.raises(Invalid, match="first-url"): + external_files.download_content("https://example.com/first-url", test_file) + with pytest.raises(Invalid, match="earlier download of.*first-url"): + external_files.download_content("https://example.com/second-url", test_file) + mock_requests_get.assert_called_once() From 3f490fe1ed023e8ac31b2a757f5d6415040d8d59 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:42:05 +1200 Subject: [PATCH 129/597] [internal_temperature] Read the RP2 on-die sensor directly instead of via the Arduino API (#18262) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../internal_temperature_rp2.cpp | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc3..2e408b3b01 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.h" +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; - temperature = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { From 22153be4cda5f4817df44c99afdff30d03b255ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 05:00:07 -0500 Subject: [PATCH 130/597] [core] Add esphome logs over web_server HTTP SSE (#17110) --- esphome/__main__.py | 65 +++- esphome/helpers.py | 18 + esphome/web_server_helpers.py | 43 +++ esphome/web_server_logs.py | 189 ++++++++++ esphome/web_server_ota.py | 19 +- tests/unit_tests/test_helpers.py | 18 +- tests/unit_tests/test_main.py | 133 +++++++ tests/unit_tests/test_web_server_helpers.py | 64 ++++ tests/unit_tests/test_web_server_logs.py | 397 ++++++++++++++++++++ tests/unit_tests/test_web_server_ota.py | 14 +- 10 files changed, 919 insertions(+), 41 deletions(-) create mode 100644 esphome/web_server_helpers.py create mode 100644 esphome/web_server_logs.py create mode 100644 tests/unit_tests/test_web_server_helpers.py create mode 100644 tests/unit_tests/test_web_server_logs.py diff --git a/esphome/__main__.py b/esphome/__main__.py index c4ba6b54d7..0ac5898268 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -21,7 +21,6 @@ from esphome.const import ( ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -29,6 +28,7 @@ from esphome.const import ( CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -42,7 +42,7 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, @@ -273,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "configured. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, or view logs over USB." ) if purpose == Purpose.UPLOADING and not has_ota(): return ( @@ -314,9 +314,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -328,7 +331,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -390,7 +397,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -483,6 +490,21 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" if CONF_MQTT not in CORE.config: @@ -1291,25 +1313,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1437,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") diff --git a/esphome/helpers.py b/esphome/helpers.py index 15d9797ce1..2731109164 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -357,6 +357,24 @@ def resolve_ip_address( return res +def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str: + """Build an ``http://host:port/path`` URL for a resolved address. + + ``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6 + literals must be wrapped in brackets in URLs; link-local addresses need a + percent-encoded zone index per RFC 6874. + """ + import socket + + ip = sockaddr[0] + if family == socket.AF_INET6: + scope = sockaddr[3] if len(sockaddr) >= 4 else 0 + host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" + else: + host_part = ip + return f"http://{host_part}:{port}{path}" + + def sort_ip_addresses(address_list: list[str]) -> list[str]: """Takes a list of IP addresses in string form, e.g. from mDNS or MQTT, and sorts them into the best order to actually try connecting to them. diff --git a/esphome/web_server_helpers.py b/esphome/web_server_helpers.py new file mode 100644 index 0000000000..f48934b185 --- /dev/null +++ b/esphome/web_server_helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for the web_server HTTP transports (OTA upload and logs).""" + +from __future__ import annotations + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import format_ip_url, resolve_ip_address +from esphome.types import ConfigType + + +def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]: + """Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``. + + Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and + formats each resolved address into an ``http://host:port/path`` URL via + :func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the + web_server OTA upload and log streaming paths. + """ + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + return [ + (sockaddr[0], format_ip_url(family, sockaddr, port, path)) + for family, _socktype, _, _, sockaddr in addr_infos + ] + + +def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]: + """Return ``(port, username, password)`` for the web_server HTTP endpoint. + + Reads the port and optional HTTP Basic-auth credentials from the validated + ``web_server:`` config, shared by the web_server OTA upload and log + streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent. + """ + web_conf = config.get(CONF_WEB_SERVER) + if not web_conf: + raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.") + auth = web_conf.get(CONF_AUTH) or {} + return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD) diff --git a/esphome/web_server_logs.py b/esphome/web_server_logs.py new file mode 100644 index 0000000000..e091e24bb7 --- /dev/null +++ b/esphome/web_server_logs.py @@ -0,0 +1,189 @@ +"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint. + +The ``web_server`` component exposes a Server-Sent Events stream at ``/events`` +that multiplexes entity state, keepalive pings, and log lines (``event: log``). +This is the logging counterpart to the web_server OTA upload path +(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that +has ``web_server:`` configured but no ``api:``. + +Only the ``event: log`` frames are rendered; the payload is the device's +already-formatted, ANSI-colored log line, so it is passed through the same +``LogParser`` + ``safe_print`` path the serial and native-API log viewers use. +The stream is long-lived and the server drops idle connections, so the reader +reconnects automatically until interrupted. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +import time +from typing import TYPE_CHECKING + +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import EsphomeError +from esphome.util import safe_print +from esphome.web_server_helpers import resolve_web_server_urls + +if TYPE_CHECKING: + from aioesphomeapi import LogParser + +_LOGGER = logging.getLogger(__name__) + +EVENTS_PATH = "/events" +# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every +# 10s, so a 30s read timeout tolerates a few missed pings before we treat the +# connection as dead and reconnect. +TIMEOUT = (10.0, 30.0) +# Pause between reconnect attempts so a downed device doesn't spin the CPU. +RECONNECT_DELAY = 1.0 +# Upper bound for the exponential backoff applied to consecutive failures, so an +# unreachable host backs off instead of retrying (and logging) once a second. +MAX_RECONNECT_DELAY = 10.0 + + +class WebServerLogsError(EsphomeError): + """Raised when the web_server log stream cannot be used (e.g. bad auth).""" + + +def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]: + """Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint.""" + urls: list[tuple[str, str]] = [] + seen: set[str] = set() + for host in hosts: + try: + resolved = resolve_web_server_urls(host, port, EVENTS_PATH) + except EsphomeError as err: + _LOGGER.warning("Error resolving IP address of %s: %s", host, err) + continue + for ip, url in resolved: + if url not in seen: + seen.add(url) + urls.append((ip, url)) + return urls + + +def _emit(data_lines: list[str], parser: LogParser) -> None: + """Render the accumulated ``data:`` lines of one ``event: log`` frame.""" + time_ = datetime.now().astimezone() + milliseconds = time_.microsecond // 1000 + time_str = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" + ) + for line in data_lines: + safe_print(parser.parse_line(line, time_str)) + + +def _consume(response: requests.Response, parser: LogParser) -> None: + """Parse the SSE stream, rendering only ``event: log`` frames. + + Implements the minimal slice of the SSE grammar the ``web_server`` stream + uses: ``field: value`` lines (with one optional leading space after the + colon) accumulated until a blank line dispatches the frame. ``id:``, + ``retry:``, and comment (``:``) lines are ignored, as are non-``log`` + events (``ping``, ``state``, ...). + """ + event_type = "message" + data_lines: list[str] = [] + # Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the + # text/event-stream response has no charset, so requests' decode_unicode + # would fall back to Latin-1 and mojibake UTF-8 log characters. + for raw in response.iter_lines(): + line = raw.decode("utf8", "backslashreplace") + if not line: + if event_type == "log" and data_lines: + _emit(data_lines, parser) + event_type = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event_type = value + elif field == "data": + data_lines.append(value) + + +def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool: + """Connect and stream one session. + + Returns ``True`` if a connection was established (even if it later + dropped), ``False`` if the connection attempt itself failed so the caller + can try the next resolved address. + """ + connected = False + _LOGGER.info("Connecting to %s ...", url) + try: + with requests.get( + url, + stream=True, + auth=auth, + timeout=TIMEOUT, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code == 401: + raise WebServerLogsError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code in (403, 404): + # Permanent: the endpoint won't appear on retry (wrong version, + # 'log' disabled, or forbidden). Surface it instead of looping. + raise WebServerLogsError( + f"Device returned HTTP {response.status_code} for " + f"{EVENTS_PATH}; the web_server log stream is unavailable. " + "Ensure 'web_server' is version 2 or higher with 'log' enabled." + ) + if response.status_code != 200: + _LOGGER.error( + "Unexpected HTTP %s response from %s", response.status_code, ip + ) + return False + connected = True + _LOGGER.info("Connected to %s", ip) + _consume(response, parser) + except requests.RequestException as err: + if connected: + _LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err) + else: + _LOGGER.warning("Could not connect to %s: %s", ip, err) + return connected + + +def run_logs( + hosts: list[str], + port: int, + username: str | None, + password: str | None, +) -> int: + """Stream logs from the first reachable host over the web_server SSE feed. + + Reconnects automatically when the stream drops and returns ``0`` on + ``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits. + """ + from aioesphomeapi import LogParser + + auth = HTTPBasicAuth(username, password) if username and password else None + parser = LogParser() + delay = RECONNECT_DELAY + try: + while True: + if not (urls := _build_urls(hosts, port)): + _LOGGER.error("Could not resolve any of: %s", ", ".join(hosts)) + connected = False + else: + # ``any`` stops at the first address that connects; when that + # stream drops we reconnect to the same set on the next pass. + connected = any(_stream(url, ip, auth, parser) for ip, url in urls) + # Reset the backoff once we reach the device; otherwise grow it + # (capped) so an unreachable host doesn't retry/log once a second. + delay = ( + RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY) + ) + time.sleep(delay) + except KeyboardInterrupt: + return 0 diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 8d0fdeecff..7b508e8527 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -12,14 +12,14 @@ import io import logging from pathlib import Path import secrets -import socket from typing import BinaryIO import requests from requests.auth import HTTPBasicAuth from esphome.core import EsphomeError -from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.helpers import ProgressBar +from esphome.web_server_helpers import resolve_web_server_urls _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def _try_upload( from esphome.core import CORE try: - addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + addr_urls = resolve_web_server_urls(host, port, OTA_PATH) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", host @@ -104,7 +104,7 @@ def _try_upload( _LOGGER.error("(If you know the IP, try --device )") raise WebServerOTAError(err) from err - if not addr_infos: + if not addr_urls: _LOGGER.error("Could not resolve %s", host) return 1, None @@ -113,16 +113,7 @@ def _try_upload( auth = HTTPBasicAuth(username, password) if username and password else None # Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does. - for af, _socktype, _, _, sa in addr_infos: - ip = sa[0] - # IPv6 literals must be wrapped in brackets in URLs; link-local - # addresses need a percent-encoded zone index per RFC 6874. - if af == socket.AF_INET6: - scope = sa[3] if len(sa) >= 4 else 0 - host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" - else: - host_part = ip - url = f"http://{host_part}:{port}{OTA_PATH}" + for ip, url in addr_urls: _LOGGER.info("Connecting to %s port %s...", ip, port) try: diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 211fbf5112..6e00e5b80f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -14,7 +14,7 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache from esphome.core import CORE, EsphomeError -from esphome.helpers import ProgressBar +from esphome.helpers import ProgressBar, format_ip_url @pytest.mark.parametrize( @@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host): assert actual is False +@pytest.mark.parametrize( + ("family", "sockaddr", "expected"), + ( + (socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"), + (socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"), + ( + socket.AF_INET6, + ("fe80::1", 8080, 0, 7), + "http://[fe80::1%257]:8080/events", + ), + ), +) +def test_format_ip_url(family, sockaddr, expected): + assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected + + @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 14b49a1a05..23bfdbcd69 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -52,6 +52,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_logging, has_web_server_ota, mqtt_get_ip, parse_args, @@ -80,6 +81,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -94,6 +96,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -816,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_web_server_only_ip() -> None: + """A web_server-only device with a static IP resolves to that IP for logs.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_logging_web_server_only_mdns() -> None: + """A web_server-only device with a .local name resolves to that hostname.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="test.local") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["test.local"] + + def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: """A resolvable device with only ota: fails logs with a missing-api message.""" setup_core( @@ -855,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: assert "set 'use_address'" in msg +def test_unresolved_default_error_logging_suggests_web_server() -> None: + """The missing-api log message lists web_server among the remediations.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "no 'api:' component is configured" in msg + assert "'web_server:'" in msg + + def test_unresolved_default_error_upload_with_ota_is_generic() -> None: """With ota: present the upload error stays generic, not transport-specific.""" setup_core( @@ -2534,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None: assert has_ota() is True +def test_has_web_server_logging_default() -> None: + """has_web_server_logging is True for a default web_server (v2, log on).""" + setup_core(config={CONF_WEB_SERVER: {}}) + assert has_web_server_logging() is True + + +def test_has_web_server_logging_without_config() -> None: + """has_web_server_logging is False when web_server is not configured.""" + setup_core(config={CONF_API: {}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_v1_has_no_events_stream() -> None: + """has_web_server_logging is False for v1, which has no /events endpoint.""" + setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_respects_log_disabled() -> None: + """has_web_server_logging is False when the web_server log option is off.""" + setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}}) + assert has_web_server_logging() is False + + def test_upload_program_web_server_only_auto_dispatches( mock_run_web_server_ota: Mock, mock_run_ota: Mock, @@ -3102,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only( ) +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server( + mock_run_logs: Mock, +) -> None: + """A web_server-only device streams logs over the HTTP SSE endpoint.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None) + + +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server_with_auth_and_port( + mock_run_logs: Mock, +) -> None: + """web_server port and basic-auth credentials are forwarded to the streamer.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + }, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret") + + +@patch("esphome.web_server_logs.run_logs") +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt_preferred_over_web_server( + mock_mqtt_show_logs: Mock, + mock_run_logs: Mock, +) -> None: + """With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server).""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + result = show_logs(CORE.config, args, ["192.168.1.100"]) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once() + mock_run_logs.assert_not_called() + + def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( diff --git a/tests/unit_tests/test_web_server_helpers.py b/tests/unit_tests/test_web_server_helpers.py new file mode 100644 index 0000000000..0280630d69 --- /dev/null +++ b/tests/unit_tests/test_web_server_helpers.py @@ -0,0 +1,64 @@ +"""Unit tests for esphome.web_server_helpers module.""" + +from __future__ import annotations + +import socket + +import pytest + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import EsphomeError +from esphome.web_server_helpers import ( + get_web_server_connection, + resolve_web_server_urls, +) + + +def test_resolve_web_server_urls_maps_ipv4_and_ipv6( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each resolved address becomes an (ip, url) pair with IPv6 bracketing.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)), + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)), + ] + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + assert resolve_web_server_urls("dev.local", 80, "/events") == [ + ("192.168.1.5", "http://192.168.1.5:80/events"), + ("fe80::1", "http://[fe80::1%257]:80/events"), + ] + + +def test_get_web_server_connection_without_auth() -> None: + """Port is returned and credentials are None when no auth is configured.""" + config = {CONF_WEB_SERVER: {CONF_PORT: 80}} + + assert get_web_server_connection(config) == (80, None, None) + + +def test_get_web_server_connection_with_auth() -> None: + """Port and HTTP Basic credentials are returned when auth is configured.""" + config = { + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + } + } + + assert get_web_server_connection(config) == (8080, "admin", "secret") + + +def test_get_web_server_connection_missing_component() -> None: + """A config without web_server raises a clear error.""" + with pytest.raises(EsphomeError, match="web_server.*not configured"): + get_web_server_connection({}) diff --git a/tests/unit_tests/test_web_server_logs.py b/tests/unit_tests/test_web_server_logs.py new file mode 100644 index 0000000000..bbdf37bed7 --- /dev/null +++ b/tests/unit_tests/test_web_server_logs.py @@ -0,0 +1,397 @@ +"""Unit tests for esphome.web_server_logs module.""" + +from __future__ import annotations + +from collections.abc import Iterator +import logging +import socket +from typing import Self +from unittest.mock import MagicMock + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome import web_server_logs +from esphome.core import EsphomeError +from esphome.web_server_logs import ( + EVENTS_PATH, + WebServerLogsError, + _build_urls, + _consume, + _stream, + run_logs, +) + +# A realistic slice of the web_server /events SSE stream: an initial ping +# carrying the config, a state frame, two log frames (one multi-line), plus +# comment/id/retry lines that must be ignored. +SSE_LINES = [ + "retry: 30000", + "id: 12345", + "event: ping", + 'data: {"title":"dev","log":true}', + "", + "event: state", + 'data: {"id":"sensor-x","state":"ON"}', + "", + "event: log", + "data: \x1b[0;32m[I][main:001]: hello\x1b[0m", + "", + ": keepalive-comment", + "event: log", + "data: line one", + "data: line two", + "", +] + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response.""" + + def __init__(self, status_code: int, lines: list[str]) -> None: + self.status_code = status_code + self._lines = lines + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def iter_lines(self) -> Iterator[bytes]: + for line in self._lines: + yield line.encode("utf8") + + +@pytest.fixture +def fake_parser() -> MagicMock: + """A LogParser whose parse_line returns the raw line unchanged.""" + parser = MagicMock() + parser.parse_line.side_effect = lambda line, time_str: line + return parser + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, + addr_infos: list[tuple[int, int, int, str, tuple]], +) -> None: + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + +# --------------------------------------------------------------------------- +# _build_urls +# --------------------------------------------------------------------------- + + +def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None: + """An IPv4 host resolves to a plain http://ip:port/events URL.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))], + ) + + assert _build_urls(["dev.local"], 80) == [ + ("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}") + ] + + +def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 literals are bracketed; link-local addresses get a %25 zone index.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))], + ) + + assert _build_urls(["dev.local"], 8080) == [ + ("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}") + ] + + +def test_build_urls_dedups_and_skips_unresolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate resolved IPs collapse to one URL; resolve errors are skipped.""" + calls: list[str] = [] + + def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]: + calls.append(host) + if host == "bad": + raise EsphomeError("nope") + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))] + + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve) + + # "good" and "dup" both resolve to 10.0.0.1, "bad" raises. + assert _build_urls(["good", "bad", "dup"], 80) == [ + ("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}") + ] + assert calls == ["good", "bad", "dup"] + + +# --------------------------------------------------------------------------- +# _consume (SSE parsing) +# --------------------------------------------------------------------------- + + +def test_consume_emits_only_log_frames( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """Only event: log data lines are printed; ping/state/comments are ignored.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, SSE_LINES), fake_parser) + + assert printed == [ + "\x1b[0;32m[I][main:001]: hello\x1b[0m", + "line one", + "line two", + ] + + +def test_consume_ignores_unterminated_trailing_frame( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """A log frame without its terminating blank line is not emitted.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser) + + assert printed == [] + + +# --------------------------------------------------------------------------- +# _stream +# --------------------------------------------------------------------------- + + +def test_stream_returns_false_when_connect_fails( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed connection logs a warning and reports not-connected.""" + + def boom(*args: object, **kwargs: object) -> _FakeResponse: + raise requests.ConnectionError("refused") + + monkeypatch.setattr(requests, "get", boom) + + with caplog.at_level(logging.WARNING): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False + ) + assert "Could not connect to 10.0.0.1" in caplog.text + + +def test_stream_returns_true_when_established_then_dropped( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A mid-stream drop after connecting reports connected so we reconnect.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + class _DroppingResponse(_FakeResponse): + def iter_lines(self) -> Iterator[bytes]: + yield b"event: log" + yield b"data: before-drop" + yield b"" + raise requests.exceptions.ChunkedEncodingError("connection lost") + + monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, [])) + + with caplog.at_level(logging.INFO): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True + ) + assert printed == ["before-drop"] + assert "reconnecting" in caplog.text + + +# --------------------------------------------------------------------------- +# run_logs +# --------------------------------------------------------------------------- + + +def test_run_logs_streams_then_reconnects_until_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dropped stream reconnects; KeyboardInterrupt during the pause exits 0.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES)) + + def stop(_delay: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", stop) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # The single stream was consumed before the reconnect pause interrupted us. + # run_logs renders through the real LogParser, which prefixes a timestamp, + # so assert on the payloads rather than exact equality. + assert len(printed) == 3 + assert "[I][main:001]: hello" in printed[0] + assert "line one" in printed[1] + assert "line two" in printed[2] + + +def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """Username + password are forwarded as HTTP Basic auth on the request.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + captured["url"] = url + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, "admin", "secret") == 0 + auth = captured["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert (auth.username, auth.password) == ("admin", "secret") + assert captured["stream"] is True + assert captured["headers"] == {"Accept": "text/event-stream"} + + +def test_run_logs_no_auth_when_credentials_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auth object is sent when username/password are not configured.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, None, None) == 0 + assert captured["auth"] is None + + +def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 aborts with a clear error rather than reconnecting forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, [])) + + with pytest.raises(WebServerLogsError, match="Authentication failed"): + run_logs(["dev.local"], 80, "admin", "bad") + + +def test_run_logs_retries_on_transient_status( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A transient non-200 (e.g. 503) is logged and the loop retries.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, [])) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert "Unexpected HTTP 503" in caplog.text + + +@pytest.mark.parametrize("status", (403, 404)) +def test_run_logs_raises_on_permanent_status( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """A permanent 403/404 aborts instead of retrying the endpoint forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, [])) + + with pytest.raises(WebServerLogsError, match=str(status)): + run_logs(["dev.local"], 80, None, None) + + +def test_run_logs_backs_off_on_repeated_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive unreachable attempts grow the reconnect delay up to the cap.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + delays: list[float] = [] + + def record(delay: float) -> None: + delays.append(delay) + if len(delays) >= 4: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", record) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0). + assert delays == [2.0, 4.0, 8.0, 10.0] + + +def test_run_logs_reports_unresolvable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """When no host resolves, an error is logged and the loop pauses/retries.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + + # Let the first reconnect pause pass so the loop continues, then interrupt + # on the second so the retry path (the ``continue``) is exercised. + sleeps = {"n": 0} + + def sleep(_delay: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 2: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", sleep) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert sleeps["n"] == 2 + assert "Could not resolve" in caplog.text diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py index 606905e36e..bde04f4db7 100644 --- a/tests/unit_tests/test_web_server_ota.py +++ b/tests/unit_tests/test_web_server_ota.py @@ -46,7 +46,7 @@ def _patch_resolve( for host, port in hosts ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) @@ -475,7 +475,7 @@ def test_run_ota_resolution_failure( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) monkeypatch.setattr(CORE, "dashboard", True) try: exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) with patch( "esphome.web_server_ota.requests.post", @@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) @@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( @@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( From 3e4661fe1e96a3546fa53d9fa4b00db8c26c4c83 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:42 +1200 Subject: [PATCH 131/597] Bump version to 2026.8.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..006f97acb7 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0-dev +PROJECT_NUMBER = 2026.8.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index a3e9f47909..623d9673bc 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.8.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8a1aa5753d45c9819940b9cbaba2f0897c6f16cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:53:42 +1200 Subject: [PATCH 132/597] Bump version to 2026.9.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..8f6048b4d8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0-dev +PROJECT_NUMBER = 2026.9.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index a3e9f47909..0dd948544f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.9.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 3c46cc9c3572d4bf70026559d95eb96f49096759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 09:59:24 -0500 Subject: [PATCH 133/597] [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) --- esphome/components/usb_uart/pl2303.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); From 1a01c34ec4fed020264e69e78f08ad3f29af8bad Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:33:45 +0000 Subject: [PATCH 134/597] Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..85a0f55263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From f2121130f971b63fd6776dcd00d8befe0fea2aee Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 12 Aug 2026 12:49:54 -0400 Subject: [PATCH 135/597] [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 99677390e04468438ec2a908bf431a6f16a63bae Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:27:14 -0500 Subject: [PATCH 136/597] Bump bundled esphome-device-builder to 1.9.6 (#18328) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..d7ae2cd4ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 RUN \ platformio settings set enable_telemetry No \ From 905485b6738213f33e1663ae3ab56cf8a3281289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 18:49:34 -0500 Subject: [PATCH 137/597] [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) --- esphome/components/ld2420/ld2420.cpp | 9 ++++++++- esphome/components/ld2420/ld2420.h | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); From 787a909aa49df808ed1d55f77d9dea0e60e6e4ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:11:32 -0500 Subject: [PATCH 138/597] [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) --- esphome/__main__.py | 144 +++++++++---- esphome/api_client.py | 87 +++++++- esphome/mqtt.py | 91 ++++++-- tests/unit_tests/test_api_client.py | 323 +++++++++++++++++++++++++++- tests/unit_tests/test_main.py | 171 +++++++++++---- tests/unit_tests/test_mqtt.py | 262 ++++++++++++++++++++++ 6 files changed, 982 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0ac5898268..1262a4525e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -567,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -604,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1438,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # A top-level ``deep_sleep:`` block means the device is only awake # briefly; cap the reconnect backoff so a wake window is not missed. deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 23bfdbcd69..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -3015,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) From 8e624b4117ab7c7cec32b6303efaa9cfe50462cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:14:50 -0500 Subject: [PATCH 139/597] [core] Retry framework downloads on transient network errors (#18330) --- esphome/framework_helpers.py | 259 ++++++++++++++------- tests/unit_tests/test_framework_helpers.py | 201 +++++++++++++++- 2 files changed, 373 insertions(+), 87 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..86d5e4eaea 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) # Attempts per mirror URL before falling through to the next mirror; only # mid-stream drops retry (resuming when the server gave a validator), -# connect errors move on immediately. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -887,37 +891,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - Returns: - The source URL. - Mirror URL templates that reference a substitution not present in - ``substitutions`` are skipped, so callers can offer templates that only - apply to some downloads. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - A path target downloads through ``download_with_resume``, so an - interrupted download resumes on the next esphome run; a file-like target - only resumes mid-stream drops within this call. - - Raises: - ValueError: If mirrors list is empty. - EsphomeError: If all download attempts fail; the message lists every - attempted URL with its individual failure reason. Also raised if - no template matched the provided substitutions. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +943,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 1. Classify the target: filesystem path or open file object - path_target: Path | None = None - f: IO[bytes] | None = None - if isinstance(target, (str, os.PathLike)): - path_target = Path(target) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) - - # 2. Try each mirror in order - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] - - for mirror in mirrors: - # 3. Apply substitutions to URL - try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) - skipped.append((mirror, f"skipped ({e!r})")) - continue - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +968,14 @@ def download_from_mirrors( failures.append((url, e)) continue - # 4. Download; mid-stream failures retry the same mirror with - # resume (see download_with_resume) instead of starting over. - # There is no checksum to verify a resumed file against, so a - # stitch is only trusted when the server proves consistency: the - # If-Range validator guarantees 206 only for unchanged content, - # and the expected total length (when the first response carried - # one) guards against short or shifted bodies. Without a - # validator the retry restarts from zero. + # File-like targets download here; mid-stream failures retry the + # same mirror with resume (see download_with_resume) instead of + # starting over. There is no checksum to verify a resumed file + # against, so a stitch is only trusted when the server proves + # consistency: the If-Range validator guarantees 206 only for + # unchanged content, and the expected total length (when the first + # response carried one) guards against short or shifted bodies. + # Without a validator the retry restarts from zero. offset = 0 expected_total = 0 validator = None @@ -1001,9 +983,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1016,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1039,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 6. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures + return None + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + Returns: + The source URL. + + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" ) + + # 2. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(mirror.format(**substitutions)) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..7451ee9b39 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -515,16 +516,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1427,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. From 7569a7b5ced61c4a2826fac165e64fceee1baccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 09:59:24 -0500 Subject: [PATCH 140/597] [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) --- esphome/components/usb_uart/pl2303.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 3c7ecd9a83..c56f43f75a 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool // Data bits line_coding[6] = channel->get_data_bits(); - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4], + line_coding[5], line_coding[6]); std::vector lc_vec(line_coding, line_coding + 7); this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); From 89489b1f0dabee05c18ce8327386dd4a660d79a5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:33:45 +0000 Subject: [PATCH 141/597] Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9c231bd0fe..85a0f55263 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.0 +aioesphomeapi==45.10.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 83cff59fddca496cdb60d66d1ea8525c62c620f6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 12 Aug 2026 12:49:54 -0400 Subject: [PATCH 142/597] [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index bd889c2c92..082639374f 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None: psram.request_external_task_stack() # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6a9d7171ec..aff1a6819f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.1 + version: 0.7.2 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From 48d6368ff9e4b3ba18b663eb84f686e5ee84065e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:27:14 -0500 Subject: [PATCH 143/597] Bump bundled esphome-device-builder to 1.9.6 (#18328) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4f5d3c3a6..d7ae2cd4ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 RUN \ platformio settings set enable_telemetry No \ From a14ea0e8fabce6ad71a3386dd5fa1ecb9edb1166 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 18:49:34 -0500 Subject: [PATCH 144/597] [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) --- esphome/components/ld2420/ld2420.cpp | 9 ++++++++- esphome/components/ld2420/ld2420.h | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..f71bec7e5f 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -746,7 +746,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index ae44b16065..977ee2eccc 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -108,7 +108,7 @@ class LD2420Component final : public Component, public uart::UARTDevice { float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); From c1a326f32e710d26e0671c56b1a74e85a37a5822 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:11:32 -0500 Subject: [PATCH 145/597] [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) --- esphome/__main__.py | 144 +++++++++---- esphome/api_client.py | 87 +++++++- esphome/mqtt.py | 91 ++++++-- tests/unit_tests/test_api_client.py | 323 +++++++++++++++++++++++++++- tests/unit_tests/test_main.py | 171 +++++++++++---- tests/unit_tests/test_mqtt.py | 262 ++++++++++++++++++++++ 6 files changed, 982 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0ac5898268..1262a4525e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -10,7 +10,7 @@ from pathlib import Path import re import sys import time -from typing import Protocol +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting @@ -71,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -567,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -604,40 +644,44 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: from datetime import datetime @@ -1438,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.api_client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=_should_subscribe_states(args), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt diff --git a/esphome/api_client.py b/esphome/api_client.py index a75f219b17..fb41075de8 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress import logging +import threading from typing import TYPE_CHECKING, Any import warnings @@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable + from aioesphomeapi.api_pb2 import ( SubscribeLogsResponse, # pylint: disable=no-name-in-module ) @@ -32,8 +35,18 @@ async def async_run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: - """Run the logs command in the event loop.""" + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ from datetime import datetime conf = config["api"] @@ -60,6 +73,41 @@ async def async_run_logs( # Decoder resolution policy lives in LogLineProcessor. processor = LogLineProcessor(config, CORE.target_platform) + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" time_ = datetime.now().astimezone() @@ -98,20 +146,53 @@ async def async_run_logs( # A top-level ``deep_sleep:`` block means the device is only awake # briefly; cap the reconnect backoff so a wake window is not missed. deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, ) try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) await asyncio.Event().wait() finally: - await stop() + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() def run_logs( config: dict[str, Any], addresses: list[str], subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, ) -> None: """Run the logs command.""" with suppress(KeyboardInterrupt): asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) ) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 3198de9d21..62deafb09a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -6,6 +6,7 @@ from pathlib import Path import ssl import tempfile import time +from typing import TYPE_CHECKING import paho.mqtt.client as mqtt @@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env from esphome.types import ConfigType from esphome.util import safe_print +if TYPE_CHECKING: + import threading + _LOGGER = logging.getLogger(__name__) @@ -164,6 +168,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, + stop_event: "threading.Event | None" = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -182,55 +187,113 @@ def get_esphome_device_ip( dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None + failed = False topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) def on_message(client, userdata, msg): - nonlocal dev_ip + nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload _LOGGER.debug(message) - data = json.loads(payload) + try: + data = json.loads(payload) + except ValueError: + data = None + if not isinstance(data, dict): + # A raise in this handler would kill paho's network thread + _LOGGER.warning("Ignoring unparsable discovery payload") + return if "name" not in data or data["name"] != dev_name: _LOGGER.warning("Wrong device answer") return - dev_ip = [] + addresses = [] key = "ip" n = 0 while key in data: - dev_ip.append(data[key]) + value = data[key] + if ( + isinstance(value, str) + and (value := value.strip()) + and value.isprintable() + ): + addresses.append(value) + else: + # repr-escaped and truncated: must not forge log lines + _LOGGER.warning( + "Ignoring invalid address in discovery answer: %s", + repr(value)[:100], + ) n = n + 1 key = "ip" + str(n) - if dev_ip: - client.disconnect() + if not addresses: + _LOGGER.warning("Device answer did not include an IP address") + failed = True + return + + dev_ip = addresses + failed = False # a complete answer wins over an earlier empty one + client.disconnect() def on_connect(client, userdata, flags, return_code): topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) + if stop_event is not None and stop_event.is_set(): + # Teardown already started; don't open a broker connection at all + return [] + + def on_disconnect(client, userdata, result_code): + nonlocal failed + if result_code != 0: + _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + failed = True + mqtt_client = prepare( config, [topic], on_message, on_connect, username, password, client_id ) + # Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs + # on the network thread and would make loop_stop() below join forever. + mqtt_client.on_disconnect = on_disconnect - mqtt_client.loop_start() - while timeout > 0: - if dev_ip is not None: - break - timeout -= 0.250 - time.sleep(0.250) - mqtt_client.loop_stop() + if stop_event is None: + import threading + + stop_event = threading.Event() # never set; wait() below is a plain sleep + stopped = stop_event.is_set() # teardown may have started during connect + try: + if not stopped: + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None or failed: + break + if stop_event.wait(0.250): + stopped = True + break + timeout -= 0.250 + finally: + # A cleanup failure must not replace the discovery result or its + # EsphomeError; a second disconnect after on_message's is harmless. + try: + mqtt_client.disconnect() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True) + mqtt_client.loop_stop() # only signals and joins; does not raise if dev_ip is None: + if stopped: + # Aborted by the caller, not a failure; stay quiet + return [] raise EsphomeError("Failed to find IP via MQTT") - _LOGGER.info("Found IP: %s", dev_ip) + _LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip)) return dev_ip diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 19ed83abe1..405567d84f 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -56,7 +56,7 @@ async def test_async_run_logs_full_flow(caplog) -> None: with ( patch.object(api_client, "async_run", mock_run), - patch.object(api_client, "APIClient") as mock_client, + patch.object(api_client, "APIClient", autospec=True) as mock_client, patch.object(api_client, "safe_print", printed.append), ): task = asyncio.get_running_loop().create_task( @@ -163,3 +163,324 @@ async def test_async_run_logs_passes_deep_sleep( await api_client.async_run_logs(config, ["1.2.3.4"]) assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_feeds_addresses(caplog) -> None: + """Addresses discovered via MQTT are fed into the running client.""" + caplog.set_level("INFO", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = asyncio.Event() + + def resolver(stop_event): + return ["10.0.0.9", "10.0.0.10"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or True + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + async with asyncio.timeout(1): + await fed.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with( + ["10.0.0.9", "10.0.0.10"] + ) + assert "Discovered address(es) via MQTT" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_no_addresses_keeps_running() -> None: + """A resolver returning nothing (failed lookup) leaves the session running.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + # The resolver owns failure handling; a failed lookup returns [] + resolver_ran.set() + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_stopped_on_teardown() -> None: + """Teardown sets the resolver's stop event so the thread exits promptly.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + # Simulate a slow broker lookup that only ends via the stop event. + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert captured_event is not None + assert captured_event.is_set() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_crash_still_stops_cleanly(caplog) -> None: + """A resolver raising unexpectedly must not skip stop() at teardown.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + raise RuntimeError("resolver blew up") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_cancels_mqtt_discovery() -> None: + """A successful connection stops the in-flight broker lookup.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + captured_event: threading.Event | None = None + resolver_started = threading.Event() + + def resolver(stop_event): + nonlocal captured_event + captured_event = stop_event + resolver_started.set() + stop_event.wait(timeout=5) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)) as mock_run, + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_started.wait, 1) + + # The runner reports a successful connection + on_connect = mock_run.call_args.kwargs["on_connect"] + on_connect() + await asyncio.sleep(0.05) + + assert captured_event is not None + assert captured_event.is_set() + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_connect_before_discovery_skips_lookup() -> None: + """A connection during async_run startup prevents the lookup from starting.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver = Mock(name="resolver") + + async def fake_async_run(*args, **kwargs): + # Connection succeeds before async_run even returns + kwargs["on_connect"]() + return stop + + with ( + patch.object(api_client, "async_run", AsyncMock(side_effect=fake_async_run)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.sleep(0.05) + assert not task.done() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + resolver.assert_not_called() + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_mqtt_resolver_duplicate_addresses_logged(caplog) -> None: + """A discovery the client rejects as already known leaves a debug trace.""" + import threading + + caplog.set_level("DEBUG", logger="esphome.api_client") + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + fed = threading.Event() + + def resolver(stop_event): + return ["1.2.3.4"] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True) as mock_client, + ): + mock_client.return_value.add_addresses.side_effect = lambda addrs: ( + fed.set() or False + ) + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(fed.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + mock_client.return_value.add_addresses.assert_called_once_with(["1.2.3.4"]) + assert "MQTT-discovered address(es) already known: 1.2.3.4" in caplog.text + assert "Discovered address(es) via MQTT" not in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_base_exception_escape_logged_at_teardown(caplog) -> None: + """A BaseException escaping the worker is reported, and stop() still runs.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + + class WorkerEscape(BaseException): + """Not an Exception, so the task-level guard must not catch it.""" + + def resolver(stop_event): + resolver_ran.set() + raise WorkerEscape("worker bailed") + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "MQTT address discovery failed" in caplog.text + stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_run_logs_stubborn_worker_cancelled_at_teardown() -> None: + """A worker that ignores the stop event is cancelled after the grace period.""" + import threading + + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}} + + stop = AsyncMock() + resolver_ran = threading.Event() + release = threading.Event() + + def resolver(stop_event): + resolver_ran.set() + # Ignore stop_event entirely; only the test releases us + release.wait(timeout=10) + return [] + + with ( + patch.object(api_client, "async_run", AsyncMock(return_value=stop)), + patch.object(api_client, "APIClient", autospec=True), + ): + task = asyncio.get_running_loop().create_task( + api_client.async_run_logs(config, ["1.2.3.4"], mqtt_resolver=resolver) + ) + await asyncio.to_thread(resolver_ran.wait, 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + release.set() + + stop.assert_awaited_once() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 23bfdbcd69..a40341e194 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _split_network_devices, _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, @@ -2879,7 +2880,9 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) @@ -2926,7 +2929,9 @@ def test_upload_program_ota_with_mqtt_empty_broker( assert exit_code == 0 assert host == "192.168.1.50" # Verify MQTT was attempted but failed gracefully - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify we fell back to the IP address expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" @@ -3015,7 +3020,10 @@ def test_show_logs_api( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.1.101"], subscribe_states=True + CORE.config, + ["192.168.1.100", "192.168.1.101"], + subscribe_states=True, + mqtt_resolver=None, ) @@ -3042,7 +3050,7 @@ def test_show_logs_api_no_states( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -3069,7 +3077,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled( assert result == 0 # Should use the FQDN directly, not try MQTT lookup mock_run_logs.assert_called_once_with( - CORE.config, ["device.example.com"], subscribe_states=True + CORE.config, ["device.example.com"], subscribe_states=True, mqtt_resolver=None ) @@ -3097,9 +3105,44 @@ def test_show_logs_api_with_mqtt_fallback( result = show_logs(CORE.config, args, devices) assert result == 0 - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.200"], subscribe_states=True + CORE.config, ["192.168.1.200"], subscribe_states=True, mqtt_resolver=None + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_api_mqtt_only_resolve_failure_falls_back_to_mqtt_logs( + mock_mqtt_show_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """With no addresses at all after a failed MQTT lookup, MQTT logging is used.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + devices = ["MQTT", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None + ) + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" ) @@ -3466,7 +3509,9 @@ def test_mqtt_get_ip() -> None: result = mqtt_get_ip(config, "user", "pass", "client-id") assert result == ["192.168.1.100", "192.168.1.101"] - mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + mock_get_ip.assert_called_once_with( + config, "user", "pass", "client-id", stop_event=None + ) def test_has_resolvable_address() -> None: @@ -3847,6 +3892,37 @@ def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None: assert result == ["unknown.local", "192.168.1.50"] +def test_split_network_devices_direct_only(tmp_path: Path) -> None: + """Direct addresses pass through deduped, with no MQTT flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["192.168.1.50", "device.local", "192.168.1.50"]) == ( + ["192.168.1.50", "device.local"], + False, + ) + + +def test_split_network_devices_mqtt_only(tmp_path: Path) -> None: + """MQTT magic strings produce no direct addresses, only the flag.""" + setup_core(tmp_path=tmp_path) + + assert _split_network_devices(["MQTTIP", "MQTT"]) == ([], True) + + +def test_split_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None: + """Hostnames in ``CORE.address_cache`` are expanded like _resolve_network_devices.""" + setup_core(tmp_path=tmp_path) + CORE.address_cache = AddressCache( + mdns_cache={ + "device-abc123.local": ["10.0.0.1", "10.0.0.2"], + } + ) + + assert _split_network_devices( + ["device-abc123.local", "MQTTIP", "192.168.1.50", "device-abc123.local"] + ) == (["10.0.0.1", "10.0.0.2", "192.168.1.50"], True) + + def test_await_discovery_timeout_returns_empty( caplog: pytest.LogCaptureFixture, ) -> None: @@ -5022,7 +5098,9 @@ def test_upload_program_ota_static_ip_with_mqttip( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with both IPs expected_firmware = ( @@ -5069,7 +5147,9 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( assert host == "192.168.2.50" # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with all unique IPs expected_firmware = ( @@ -5116,7 +5196,9 @@ def test_upload_program_ota_mqttip_deduplication( assert host == "192.168.1.100" # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) # Note: Current implementation doesn't dedupe, so we'll get the IP twice @@ -5136,7 +5218,9 @@ def test_show_logs_api_static_ip_with_mqttip( This tests the scenario where a device has manual_ip (static IP) configured and MQTT is also configured. The devices list contains both the static IP - and "MQTTIP" magic string. + and "MQTTIP" magic string. The MQTT lookup must not block startup; it is + handed to run_logs as a deferred resolver instead (issue #18311), while + still being reachable as a fallback for a stale static IP. """ setup_core( config={ @@ -5157,12 +5241,19 @@ def test_show_logs_api_static_ip_with_mqttip( assert result == 0 - # Verify MQTT was resolved - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # The broker must not be contacted before run_logs starts + mock_mqtt_get_ip.assert_not_called() - # Verify run_logs was called with both IPs - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100", "192.168.2.50"], subscribe_states=True + # run_logs gets the static IP immediately plus a deferred MQTT resolver + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) + assert mock_run_logs.call_args.kwargs["subscribe_states"] is True + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + + # Invoking the resolver performs the MQTT lookup (the #11260 fallback) + assert resolver(None) == ["192.168.2.50"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5171,7 +5262,7 @@ def test_show_logs_api_multiple_mqttip_resolves_once( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + """Test that multiple MQTT magic strings collapse into one deferred resolver.""" setup_core( config={ "logger": {}, @@ -5191,16 +5282,16 @@ def test_show_logs_api_multiple_mqttip_resolves_once( assert result == 0 - # Verify MQTT was only resolved once despite multiple MQTT magic strings - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + # Note: "MQTT" is a different magic string from "MQTTIP", but both defer + # to the same single resolver; the broker is not contacted eagerly + mock_mqtt_get_ip.assert_not_called() + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) - # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution - # The _resolve_network_devices helper filters out both after first resolution - mock_run_logs.assert_called_once_with( - CORE.config, - ["192.168.2.50", "192.168.2.51", "192.168.1.100"], - subscribe_states=True, + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == ["192.168.2.50", "192.168.2.51"] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -5238,7 +5329,9 @@ def test_upload_program_ota_mqtt_timeout_fallback( assert host == "192.168.1.100" # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + mock_mqtt_get_ip.assert_called_once_with( + config, "user", "pass", "client", stop_event=None + ) # Verify espota2.run_ota was called with only the static IP (MQTT failed) expected_firmware = ( @@ -5254,7 +5347,7 @@ def test_show_logs_api_mqtt_timeout_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, ) -> None: - """Test show_logs falls back to other devices when MQTT times out.""" + """Test show_logs proceeds with the static IP when MQTT times out.""" setup_core( config={ "logger": {}, @@ -5273,15 +5366,17 @@ def test_show_logs_api_mqtt_timeout_fallback( result = show_logs(CORE.config, args, devices) - # Should succeed using the static IP even though MQTT failed + # Logs start on the static IP without waiting for the broker assert result == 0 + mock_run_logs.assert_called_once() + assert mock_run_logs.call_args.args == (CORE.config, ["192.168.1.100"]) - # Verify MQTT was attempted - mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") - - # Verify run_logs was called with only the static IP (MQTT failed) - mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + # The deferred resolver owns the failure policy: it logs a warning and + # returns no addresses so the session keeps running on the known ones + resolver = mock_run_logs.call_args.kwargs["mqtt_resolver"] + assert resolver(None) == [] + mock_mqtt_get_ip.assert_called_once_with( + CORE.config, "user", "pass", "client", stop_event=None ) @@ -6764,7 +6859,7 @@ def test_command_run_passes_no_states_to_show_logs( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=False + CORE.config, ["192.168.1.100"], subscribe_states=False, mqtt_resolver=None ) @@ -6805,7 +6900,7 @@ def test_command_run_defaults_subscribe_states_true( assert result == 0 mock_run_logs.assert_called_once_with( - CORE.config, ["192.168.1.100"], subscribe_states=True + CORE.config, ["192.168.1.100"], subscribe_states=True, mqtt_resolver=None ) diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 4c2c34dff1..1ae10d0eb5 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +import threading +import time +from unittest.mock import MagicMock, patch + import pytest from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME @@ -89,3 +94,260 @@ def test_get_esphome_device_ip_missing_name() -> None: match="Cannot discover IP via MQTT as the config does not include the device name:", ): get_esphome_device_ip(config) + + +def _discovery_config() -> dict: + return { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + +def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: + """Deliver a discovery answer as soon as the network loop starts.""" + + def deliver(*args, **kwargs): + msg = MagicMock() + msg.payload = payload + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = deliver + + +def test_get_esphome_device_ip_success() -> None: + """A device answer on the discovery topic returns its IPs.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + {"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"} + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5", "10.0.0.6"] + client.loop_stop.assert_called_once_with() + # Once from on_message on receiving the answer, once from the finally + assert client.disconnect.call_count == 2 + + +def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None: + """A stop event set before the call returns [] without touching the broker.""" + stop_event = threading.Event() + stop_event.set() + + with patch("esphome.mqtt.prepare") as mock_prepare: + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + mock_prepare.assert_not_called() + + +def test_get_esphome_device_ip_stop_event_aborts_wait() -> None: + """A stop event set mid-wait exits quietly with no addresses.""" + stop_event = threading.Event() + client = MagicMock() + # Simulate teardown starting right after the network loop spins up + client.loop_start.side_effect = stop_event.set + + start = time.monotonic() + with patch("esphome.mqtt.prepare", return_value=client): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + # An abort is not a failure and must be nowhere near the 25s timeout + assert result == [] + assert time.monotonic() - start < 5 + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_timeout_raises() -> None: + """No answer within the timeout raises EsphomeError (default stop event path).""" + client = MagicMock() + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None: + """A stop event set while the broker connect is in flight still cleans up.""" + stop_event = threading.Event() + client = MagicMock() + + def prepare_and_stop(*args): + stop_event.set() + return client + + with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop): + result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event) + + assert result == [] + client.loop_start.assert_not_called() + client.disconnect.assert_called_once_with() + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_replaces_reconnect_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """The one-shot discovery client must not inherit the reconnect-forever + handler, which would make loop_stop() join the network thread forever; + its replacement still reports a broker-initiated disconnect.""" + client = MagicMock() + prepare_handler = MagicMock() + client.on_disconnect = prepare_handler + + with ( + patch("esphome.mqtt.prepare", return_value=client), + pytest.raises(EsphomeError, match="Failed to find IP via MQTT"), + ): + get_esphome_device_ip(_discovery_config(), timeout=0.25) + + assert client.on_disconnect is not prepare_handler + client.on_disconnect(client, None, 0) + assert "Disconnected from MQTT broker" not in caplog.text + client.on_disconnect(client, None, 5) + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_answer_without_ip_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A device answer with no IP fields fails promptly, not at the timeout.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, client, json.dumps({"name": "test-device"}).encode() + ) + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Device answer did not include an IP address" in caplog.text + + +@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"]) +def test_get_esphome_device_ip_unparsable_payload_ignored( + caplog: pytest.LogCaptureFixture, + payload: bytes, +) -> None: + """Garbage on the discovery topic must not kill paho's network thread.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start(mock_prepare, client, payload) + + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=0) + + assert "Ignoring unparsable discovery payload" in caplog.text + + +def test_get_esphome_device_ip_broker_disconnect_fails_fast( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broker-initiated disconnect aborts the wait instead of timing out.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client): + + def drop_connection(*args, **kwargs): + client.on_disconnect(client, None, 5) + + client.loop_start.side_effect = drop_connection + + start = time.monotonic() + with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"): + get_esphome_device_ip(_discovery_config(), timeout=5) + + assert time.monotonic() - start < 1 + assert "Disconnected from MQTT broker (5)" in caplog.text + + +def test_get_esphome_device_ip_sends_discovery_ping() -> None: + """Connecting publishes the discovery ping for the device.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + + def connect_then_answer(*args, **kwargs): + on_connect = mock_prepare.call_args.args[3] + on_connect(client, None, None, 0) + msg = MagicMock() + msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() + mock_prepare.call_args.args[2](client, None, msg) + + client.loop_start.side_effect = connect_then_answer + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.publish.assert_called_once_with( + "esphome/ping/test-device", None, retain=False + ) + + +def test_get_esphome_device_ip_disconnect_error_does_not_mask_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """A cleanup failure must not replace the discovery result.""" + client = MagicMock() + # First disconnect (from on_message) succeeds; the finally's fails + client.disconnect.side_effect = [None, OSError("socket already closed")] + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + client.loop_stop.assert_called_once_with() + + +def test_get_esphome_device_ip_invalid_address_values_skipped( + caplog: pytest.LogCaptureFixture, +) -> None: + """Non-string or non-printable ip values are skipped, valid ones kept.""" + client = MagicMock() + + with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare: + _deliver_on_loop_start( + mock_prepare, + client, + json.dumps( + { + "name": "test-device", + "ip": 1234, + "ip1": "x\n[00:00:00][I][forged] fake line", + "ip2": " 10.0.0.5 ", + } + ).encode(), + ) + + result = get_esphome_device_ip(_discovery_config()) + + assert result == ["10.0.0.5"] + assert caplog.text.count("Ignoring invalid address in discovery answer") == 2 + assert "forged" not in "".join( + r.getMessage() for r in caplog.records if "Found IP" in r.getMessage() + ) From bd58b5c8b31fb97618335e163170c12beb2e1c4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 19:14:50 -0500 Subject: [PATCH 146/597] [core] Retry framework downloads on transient network errors (#18330) --- esphome/framework_helpers.py | 259 ++++++++++++++------- tests/unit_tests/test_framework_helpers.py | 201 +++++++++++++++- 2 files changed, 373 insertions(+), 87 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 6ed608b171..86d5e4eaea 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__) # Attempts per mirror URL before falling through to the next mirror; only # mid-stream drops retry (resuming when the server gave a validator), -# connect errors move on immediately. +# connect errors move on to the next mirror immediately. _MIRROR_ATTEMPTS = 3 +# Passes over the whole mirror list when a transient network error is in +# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff). +_MIRROR_SWEEP_ATTEMPTS = 3 + def get_project_link_flags() -> list[str]: """Return the sorted -Wl, linker flags from the current build.""" @@ -887,37 +891,51 @@ def _failure_reason(e: Exception) -> str: return str(e).split(" for url: ", maxsplit=1)[0] or repr(e) -def download_from_mirrors( - mirrors: list[str], - substitutions: dict[str, str], - target: io.RawIOBase | IO[bytes] | PathType, - timeout: int = 30, -) -> str: +def _spent_attempts_error(e: Exception, attempts: int) -> Exception: + """Wrap a failure whose mirror already consumed download attempts, so + the sweep classifies it as permanent.""" + from esphome.core import EsphomeError + + err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}") + err.__cause__ = e + return err + + +def _is_transient_download_error(e: Exception) -> bool: + """Return True when a download failure is worth retrying. + + Connection-level failures and HTTP 429/5xx are transient. Other HTTP + errors, local errors, and exhausted-attempts EsphomeError wrappers + (their per-mirror retries are already spent) are permanent. """ - Download file from multiple mirrors with substitution support. + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when actually downloading, never during config validation. + import requests - Args: - mirrors: list of mirror URLs - substitutions: Dictionary of substitutions to apply to URLs - target: Target file path or file-like object - timeout: Download timeout in seconds + if isinstance(e, requests.exceptions.HTTPError): + resp = e.response + return resp is not None and (resp.status_code == 429 or resp.status_code >= 500) + return isinstance( + e, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ), + ) - Returns: - The source URL. - Mirror URL templates that reference a substitution not present in - ``substitutions`` are skipped, so callers can offer templates that only - apply to some downloads. +def _try_mirrors_once( + urls: list[str], + path_target: Path | None, + f: IO[bytes] | None, + timeout: int, + failures: list[tuple[str, Exception]], +) -> str | None: + """Single pass over the resolved mirror ``urls``, one try per URL. - A path target downloads through ``download_with_resume``, so an - interrupted download resumes on the next esphome run; a file-like target - only resumes mid-stream drops within this call. - - Raises: - ValueError: If mirrors list is empty. - EsphomeError: If all download attempts fail; the message lists every - attempted URL with its individual failure reason. Also raised if - no template matched the provided substitutions. + Returns the source URL on success, or None with each URL's exception + appended to ``failures``. """ # Imported lazily: requests is a heavy import (~85ms) and is only # needed when actually downloading, never during config validation. @@ -925,43 +943,7 @@ def download_from_mirrors( from esphome.core import EsphomeError - ensure_happy_eyeballs() - - # 1. Classify the target: filesystem path or open file object - path_target: Path | None = None - f: IO[bytes] | None = None - if isinstance(target, (str, os.PathLike)): - path_target = Path(target) - elif isinstance(target, (io.RawIOBase, io.IOBase)): - f = target - else: - raise TypeError( - f"target must be str, Path, or file-like object: {type(target)}" - ) - - # 2. Try each mirror in order - failures: list[tuple[str, Exception]] = [] - skipped: list[tuple[str, str]] = [] - - for mirror in mirrors: - # 3. Apply substitutions to URL - try: - url = mirror.format(**substitutions) - except KeyError as e: - # The template references a substitution not provided for - # this download (e.g. SHORT_VERSION only exists for x.y.0 - # versions) - expected, the template just doesn't apply. - _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) - skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) - continue - except (IndexError, ValueError) as e: - # A malformed template (unbalanced braces, bad format spec) - # is an authoring error, not an expected fallthrough - warn - # even if a later mirror succeeds. - _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) - skipped.append((mirror, f"skipped ({e!r})")) - continue - + for url in urls: _LOGGER.debug("Trying to download from %s", url) # Path targets delegate to download_with_resume so a partial @@ -986,14 +968,14 @@ def download_from_mirrors( failures.append((url, e)) continue - # 4. Download; mid-stream failures retry the same mirror with - # resume (see download_with_resume) instead of starting over. - # There is no checksum to verify a resumed file against, so a - # stitch is only trusted when the server proves consistency: the - # If-Range validator guarantees 206 only for unchanged content, - # and the expected total length (when the first response carried - # one) guards against short or shifted bodies. Without a - # validator the retry restarts from zero. + # File-like targets download here; mid-stream failures retry the + # same mirror with resume (see download_with_resume) instead of + # starting over. There is no checksum to verify a resumed file + # against, so a stitch is only trusted when the server proves + # consistency: the If-Range validator guarantees 206 only for + # unchanged content, and the expected total length (when the first + # response carried one) guards against short or shifted bodies. + # Without a validator the retry restarts from zero. offset = 0 expected_total = 0 validator = None @@ -1001,9 +983,12 @@ def download_from_mirrors( try: resp, offset = _open_ranged(url, offset, timeout, validator) except (requests.RequestException, OSError) as e: - # Connect/HTTP error, no bytes flowed — next mirror. + # Connect/HTTP error, no bytes flowed — next mirror. Wrap + # when earlier attempts were already spent on this mirror. _LOGGER.debug("Failed to download %s: %s", url, str(e)) - failures.append((url, e)) + failures.append( + (url, _spent_attempts_error(e, attempt + 1) if attempt else e) + ) break try: @@ -1031,7 +1016,7 @@ def download_from_mirrors( _LOGGER.debug("Downloaded successfully from: %s", url) - # 5. Reset file pointer and return + # Reset file pointer and return f.seek(0) return url @@ -1054,16 +1039,124 @@ def download_from_mirrors( ) offset = 0 if attempt == _MIRROR_ATTEMPTS - 1: - failures.append((url, e)) + failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS))) - # 6. Report every attempted URL if all mirrors failed. Falling back - # past an early mirror is normal (e.g. only one of the framework URL - # templates matches a given version's tag), so raising only the last - # error would hide the failure that actually matters. - if failures: - attempts = "".join( - f"\n {url}\n {_failure_reason(e)}" for url, e in failures + return None + + +def download_from_mirrors( + mirrors: list[str], + substitutions: dict[str, str], + target: io.RawIOBase | IO[bytes] | PathType, + timeout: int = 30, +) -> str: + """ + Download file from multiple mirrors with substitution support. + + Args: + mirrors: list of mirror URLs + substitutions: Dictionary of substitutions to apply to URLs + target: Target file path or file-like object + timeout: Download timeout in seconds + + Returns: + The source URL. + + Mirror URL templates that reference a substitution not present in + ``substitutions`` are skipped, so callers can offer templates that only + apply to some downloads. + + A path target downloads through ``download_with_resume``, so an + interrupted download resumes on the next esphome run; a file-like target + only resumes mid-stream drops within this call. + + When every mirror fails and at least one failure is transient (dropped + connection, timeout, HTTP 429/5xx), the whole list is retried with a + short backoff; permanent failures (e.g. 404) raise immediately. + + Raises: + ValueError: If mirrors list is empty. + EsphomeError: If all download attempts fail; the message lists every + attempted URL with its individual failure reason. Also raised if + no template matched the provided substitutions. + """ + from esphome.core import EsphomeError + + ensure_happy_eyeballs() + + # 1. Classify the target: filesystem path or open file object + path_target: Path | None = None + f: IO[bytes] | None = None + if isinstance(target, (str, os.PathLike)): + path_target = Path(target) + elif isinstance(target, (io.RawIOBase, io.IOBase)): + f = target + else: + raise TypeError( + f"target must be str, Path, or file-like object: {type(target)}" ) + + # 2. Resolve the mirror templates (invariant across retry sweeps) + urls: list[str] = [] + skipped: list[tuple[str, str]] = [] + for mirror in mirrors: + try: + urls.append(mirror.format(**substitutions)) + except KeyError as e: + # The template references a substitution not provided for + # this download (e.g. SHORT_VERSION only exists for x.y.0 + # versions) - expected, the template just doesn't apply. + _LOGGER.debug("Skipping mirror %s: %s not available", mirror, e) + skipped.append((mirror, f"not applicable ({e.args[0]} not available)")) + except (IndexError, ValueError) as e: + # A malformed template (unbalanced braces, bad format spec) + # is an authoring error, not an expected fallthrough - warn + # even if a later mirror succeeds. + _LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e) + skipped.append((mirror, f"skipped ({e!r})")) + + # 3. Sweep the mirror list, retrying transient failures with backoff: + # a single pass keeps mirror failover fast, re-sweeping keeps one + # network blip from failing the build when only one mirror applies. + failures: list[tuple[str, Exception]] = [] + for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1): + sweep_failures: list[tuple[str, Exception]] = [] + if ( + url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures) + ) is not None: + return url + failures.extend(sweep_failures) + # Permanent failures (404, verification mismatch) won't heal; + # only retry when a transient error is in the mix (as git.py does). + transient = next( + ((u, e) for u, e in sweep_failures if _is_transient_download_error(e)), + None, + ) + if transient is None: + break + if sweep < _MIRROR_SWEEP_ATTEMPTS: + delay = 2**sweep + _LOGGER.warning( + "Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)", + transient[0], + _failure_reason(transient[1]), + delay, + sweep + 1, + _MIRROR_SWEEP_ATTEMPTS, + ) + time.sleep(delay) + + # 4. Report every attempted URL if all mirrors failed. failures spans + # all sweeps (deduplicated by URL and reason), so neither an early + # mirror's failure nor an earlier sweep's failure mode is hidden. + if failures: + seen: set[tuple[str, str]] = set() + attempts = "" + for url, e in failures: + reason = _failure_reason(e) + if (url, reason) not in seen: + seen.add((url, reason)) + attempts += f"\n {url}\n {reason}" attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped) raise EsphomeError( f"Failed to download from all mirrors:{attempts}" diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 08751879c2..7451ee9b39 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import sys import tarfile -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import zipfile import pytest @@ -23,6 +23,7 @@ from esphome.core import EsphomeError from esphome.framework_helpers import ( _7z_extract_all, _detect_archive_root, + _is_transient_download_error, _rename_with_retry, _tar_extract_all, _zip_extract_all, @@ -515,16 +516,23 @@ class TestArchiveExtractAll: # --------------------------------------------------------------------------- -def _mock_response(content: bytes, ok: bool = True) -> MagicMock: +def _mock_response( + content: bytes, ok: bool = True, status: int | None = None +) -> MagicMock: + """A fake requests response. The HTTPError carries the response (as + ``raise_for_status`` on a real response) so the transient classifier + can see its ``status``; failures default to a permanent 404.""" + if status is None: + status = 200 if ok else 404 r = MagicMock() r.__enter__.return_value = r r.__exit__.return_value = False - r.status_code = 200 + r.status_code = status r.ok = ok if ok: r.raise_for_status.return_value = None else: - r.raise_for_status.side_effect = req.HTTPError("503") + r.raise_for_status.side_effect = req.HTTPError(str(status), response=r) r.headers = {"content-length": "0"} # suppress ProgressBar r.iter_content.return_value = [content] if content else [] return r @@ -1419,6 +1427,191 @@ class TestDownloadFromMirrors: assert target.exists() assert target.read_bytes() == b"" + @pytest.mark.parametrize("target_kind", ["path", "file-like"]) + def test_transient_failure_retries_mirror_sweep( + self, tmp_path: Path, target_kind: str + ) -> None: + """A transient connect error on the only applicable mirror retries the + whole mirror list with backoff instead of failing the build.""" + target = tmp_path / "idf.tar.xz" if target_kind == "path" else io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("Remote end closed connection"), + _mock_response(b"data"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, target) + assert url == "https://mirror1.com/f" + data = target.read_bytes() if target_kind == "path" else target.getvalue() + assert data == b"data" + assert mock_get.call_count == 2 + mock_sleep.assert_called_once_with(2) + + def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None: + """An HTTP 404 will not heal on its own; fail after a single pass.""" + with ( + patch( + "requests.get", return_value=_mock_response(b"", ok=False, status=404) + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 1 + mock_sleep.assert_not_called() + + def test_transient_failure_exhausts_sweeps(self, tmp_path: Path) -> None: + """A persistent transient error gives up after the configured number + of passes, with 2s/4s backoff, and still lists the attempted URL.""" + with ( + patch("requests.get", side_effect=req.ConnectionError("down")) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert mock_get.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert "https://mirror1.com/f" in str(ei.value) + + def test_mixed_permanent_and_transient_retries_sweep(self, tmp_path: Path) -> None: + """One mirror 404s permanently while another hits a transient error; + the transient failure makes the whole list worth another pass.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=404), + req.ConnectionError("down"), + _mock_response(b"", ok=False, status=404), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors( + ["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest + ) + assert url == "https://mirror2.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_http_5xx_retries_sweep(self, tmp_path: Path) -> None: + """A real 5xx (response attached to the HTTPError) is transient.""" + dest = tmp_path / "out.bin" + with ( + patch( + "requests.get", + side_effect=[ + _mock_response(b"", ok=False, status=503), + _mock_response(b"data"), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + ): + url = download_from_mirrors(["https://mirror1.com/f"], {}, dest) + assert url == "https://mirror1.com/f" + assert dest.read_bytes() == b"data" + mock_sleep.assert_called_once_with(2) + + def test_error_reports_failure_modes_from_all_sweeps(self, tmp_path: Path) -> None: + """A failure mode that changes between sweeps stays in the final + error; the first failure (the one that started the retries) is + chained as the cause.""" + with ( + patch( + "requests.get", + side_effect=[ + req.ConnectionError("dropped by middlebox"), + _mock_response(b"", ok=False, status=404), + ], + ), + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="all mirrors") as ei, + ): + download_from_mirrors(["https://mirror1.com/f"], {}, tmp_path / "out.bin") + assert "dropped by middlebox" in str(ei.value) + assert "404" in str(ei.value) + assert isinstance(ei.value.__cause__, req.ConnectionError) + mock_sleep.assert_called_once_with(2) + + def test_exhausted_mid_stream_attempts_not_swept(self) -> None: + """A file-like mirror that spent all its mid-stream attempts is not + retried again at the sweep level (unlike a path target, it has no + part file to resume from on a later sweep).""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[_interrupted_response(b"1234") for _ in range(3)], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 3 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 3 + mock_sleep.assert_not_called() + + def test_mid_stream_drop_then_connect_error_not_swept(self) -> None: + """A connect error on a later attempt (after a mid-stream drop spent + one) also counts as spent budget and does not re-arm the sweep.""" + buf = io.BytesIO() + with ( + patch( + "requests.get", + side_effect=[ + _interrupted_response(b"1234"), + req.ConnectionError("down"), + ], + ) as mock_get, + patch("esphome.framework_helpers.time.sleep") as mock_sleep, + pytest.raises(EsphomeError, match="failed after 2 attempts"), + ): + download_from_mirrors(["https://mirror1.com/f"], {}, buf) + assert mock_get.call_count == 2 + mock_sleep.assert_not_called() + + +def _http_error(status: int) -> req.HTTPError: + """An HTTPError carrying a response with the given status, as raised by + ``raise_for_status`` on a real response.""" + resp = MagicMock() + resp.status_code = status + return req.HTTPError(str(status), response=resp) + + +class TestIsTransientDownloadError: + def test_connection_errors_are_transient(self) -> None: + assert _is_transient_download_error(req.ConnectionError("reset")) + assert _is_transient_download_error(req.Timeout("timed out")) + assert _is_transient_download_error( + req.exceptions.ChunkedEncodingError("dropped") + ) + + def test_http_statuses(self) -> None: + assert not _is_transient_download_error(_http_error(404)) + assert not _is_transient_download_error(_http_error(403)) + assert _is_transient_download_error(_http_error(429)) + assert _is_transient_download_error(_http_error(503)) + + def test_http_error_without_response_is_permanent(self) -> None: + assert not _is_transient_download_error(req.HTTPError("boom")) + + def test_exhausted_resume_attempts_are_permanent(self) -> None: + """download_with_resume already spent its own resume attempts; its + EsphomeError wrapper is not retried again at the sweep level.""" + wrapped = EsphomeError("Failed to download after 3 attempts") + wrapped.__cause__ = req.ConnectionError("down") + assert not _is_transient_download_error(wrapped) + + def test_unrelated_errors_are_permanent(self) -> None: + assert not _is_transient_download_error(OSError("disk full")) + assert not _is_transient_download_error(EsphomeError("size mismatch")) + def test_importing_framework_helpers_does_not_import_requests() -> None: """Importing framework_helpers must not drag in requests. From d3e27054f6f621e712d85e1bba4172ea6d7444c5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:30:47 +1200 Subject: [PATCH 147/597] Bump version to 2026.8.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 006f97acb7..a8c77f4bb8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b1 +PROJECT_NUMBER = 2026.8.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 623d9673bc..b6770d0001 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b1" +__version__ = "2026.8.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9cc05b30d401daba19a061fe610a952590987961 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 21:09:01 -0500 Subject: [PATCH 148/597] [web_server_base] Stop deleting the web server on captive portal teardown (#18324) --- .../web_server/ota/ota_web_server.cpp | 2 +- .../web_server_base/web_server_base.h | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } From f337d0acff4dfae8e09021508f2bfdff070d2903 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:13:29 +0000 Subject: [PATCH 149/597] Bump pylint from 4.0.6 to 4.0.7 (#18320) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0905fe6be1..1832ffd433 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.6 +pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From f1c40867783064dc711be70c3412d0066a80c378 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 13 Aug 2026 13:36:11 +0200 Subject: [PATCH 150/597] [const] Move CONF_SLOT to components/const (#18350) Co-authored-by: Oliver Kleinecke --- esphome/components/const/__init__.py | 1 + esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/sendspin/image/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..2d02c7d179 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -35,6 +35,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" +CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index b15ae53711..c6a714aace 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -33,7 +33,6 @@ CONF_DATA_READY_PIN = "data_ready_pin" CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" -CONF_SLOT = "slot" CONF_SPI_MODE = "spi_mode" # Shared fields for both transport modes diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 94d6e7cfca..3c6c82b009 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -3,6 +3,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image +from esphome.components.const import CONF_SLOT from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata import esphome.config_validation as cv from esphome.const import ( @@ -45,7 +46,6 @@ MAX_IMAGE_DIMENSION = 32767 MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) -CONF_SLOT = "slot" CONF_CURRENT_IMAGE = "current_image" CONF_TRANSITION_IMAGE = "transition_image" CONF_ON_IMAGE_DISPLAY = "on_image_display" From 87045ab9c020e95da9264c26391ffbf2bc5a4438 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:14 -0500 Subject: [PATCH 151/597] Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85a0f55263..683008400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From c7940382a9a210226b2e2a7eee668dc6dc4c21a5 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Thu, 13 Aug 2026 20:18:22 +0200 Subject: [PATCH 152/597] [const] move CONF_LABEL to components/const (#18354) Co-authored-by: Oliver Kleinecke Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/const/__init__.py | 1 + esphome/components/display_menu_base/__init__.py | 2 +- esphome/components/lvgl/widgets/label.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 2d02c7d179..3ba89d2838 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c..2120abe5f7 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -3,6 +3,7 @@ import re from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_LABEL from esphome.components.number import Number from esphome.components.select import Select from esphome.components.switch import Switch @@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") CONF_ROTARY = "rotary" CONF_JOYSTICK = "joystick" -CONF_LABEL = "label" CONF_MENU = "menu" CONF_BACK = "back" CONF_SELECT = "select" diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717..54c9819d2b 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -1,3 +1,4 @@ +from esphome.components.const import CONF_LABEL import esphome.config_validation as cv from esphome.const import CONF_TEXT @@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA from ..types import LvText from . import Widget, WidgetType -CONF_LABEL = "label" - class LabelType(WidgetType): def __init__(self): From db5173697a40c92e6f7a4dfc1d97c59580bc9271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:22:48 -0500 Subject: [PATCH 153/597] [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) --- .../components/ble_device_base/__init__.py | 20 ++- .../components/esp32_ble_tracker/__init__.py | 71 +++++++++- .../test_scan_parameter_validation.py | 7 +- .../esp32_ble_tracker/__init__.py | 0 .../test_scan_window_default.py | 122 ++++++++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/__init__.py create mode 100644 tests/component_tests/esp32_ble_tracker/test_scan_window_default.py diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) From 137351fa8d85f27130b8ccfcbdfa9a42555a6635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:27:01 -0500 Subject: [PATCH 154/597] [esp32_ble] Silence spurious warnings for local key GAP events (#18359) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: From 191686c5b3e106581ec58ab0ee15e6b8af4e9527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:30:53 -0500 Subject: [PATCH 155/597] [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) --- .../wifi/wifi_component_esp8266.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); From 7420d238673fb3a593b9314bef93f8e8e1cd4546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:06 -0500 Subject: [PATCH 156/597] [ota] Retry uploads that fail from network errors (#18332) --- esphome/espota2.py | 158 ++++++++++--- tests/unit_tests/test_espota2.py | 382 +++++++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 47 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 945c2458b3642964232503bf162bb9d1ab657d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:19 -0500 Subject: [PATCH 157/597] [core] Load component aliases from a generated registry (#18335) --- .github/workflows/ci.yml | 1 + esphome/component_aliases.py | 10 ++++++ esphome/loader.py | 61 +++++++++++++-------------------- script/build_alias_registry.py | 59 +++++++++++++++++++++++++++++++ tests/unit_tests/test_loader.py | 28 +++++++++++++++ 5 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 esphome/component_aliases.py create mode 100755 script/build_alias_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..b603e68ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # If `domain` is the legacy name of a renamed component, redirect to the # canonical module so the rest of the loader (and every caller of # `get_component(legacy)`) transparently sees the new component. - alias_map = _get_alias_map() - if domain in alias_map: - canonical = alias_map[domain] - manif = _lookup_module(canonical, exception) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # A component can declare ``ALIASES = ["legacy_name"]`` (and optionally -# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two -# integrations are then wired up automatically: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # dependency checks, schema validation and codegen all see only the # canonical name. # -# Both lookups are populated by ``_build_alias_map``, which **AST-parses** -# every component's ``__init__.py`` rather than importing it. That keeps the -# cost low: scanning ~400 components on disk takes ~5 ms instead of the -# multi-second cost of executing every component's import side-effects. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: removal_version: str | None -def _ensure_alias_caches() -> None: - """Populate both alias caches from a single directory scan. - - ``_build_alias_map`` returns both maps together, so building them in one - shot avoids scanning every component's ``__init__.py`` twice when a run - needs both the canonical map (loader) and the metadata map (config - pre-pass). - """ - global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE - if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: - _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() - - -def _get_alias_map() -> dict[str, str]: - """Return the legacy-name → canonical-name map, building it lazily.""" - _ensure_alias_caches() - return _ALIAS_MAP_CACHE - - def get_alias_metadata() -> dict[str, AliasMeta]: - """Return the legacy-name → :class:`AliasMeta` map (cached). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # least three parts, so ``parts[2]`` (the domain) always exists. parts = fullname.split(".") domain = parts[2] - alias_map = _get_alias_map() - if domain not in alias_map: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits From 37782f72069e10f0d6a32c79a46a4d58180cb240 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:54:37 -0500 Subject: [PATCH 158/597] Bump prek from 0.4.12 to 0.4.13 (#18362) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1832ffd433..95ee97437d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.12 # also change in .github/workflows/ci.yml when updating +prek==0.4.13 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 45a056e33774333778e0264222d4434aac434a36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:54:51 -0500 Subject: [PATCH 159/597] Bump platformdirs from 4.11.1 to 4.11.2 (#18363) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 683008400a..6bc8bdf74a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.1 # native esp-idf toolchain global cache dir +platformdirs==4.11.2 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From dd51624fbb909ccaa902e8d38480b91b782fb6ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 20:06:48 -0500 Subject: [PATCH 160/597] [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/infrared/infrared.cpp | 8 +- esphome/components/mqtt/__init__.py | 63 --- esphome/components/prometheus/__init__.py | 6 - .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/core/application.h | 8 +- esphome/core/entity_base.cpp | 52 +-- esphome/core/entity_base.h | 80 ++-- esphome/core/entity_helpers.py | 190 ++++---- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 14 +- esphome/core/preferences.cpp | 25 - esphome/core/preferences.h | 12 - esphome/helpers.py | 15 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 -- .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 -- tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 - .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ------- tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 ---------- tests/unit_tests/core/test_entity_helpers.py | 432 ++++++++++-------- .../object_id_conflict_mqtt.yaml | 22 - .../object_id_conflict_no_mqtt.yaml | 15 - .../test_preference_hash_stability.py | 34 +- 32 files changed, 489 insertions(+), 1132 deletions(-) delete mode 100644 esphome/core/preferences.cpp delete mode 100644 tests/integration/fixtures/preference_key_migration.yaml delete mode 100644 tests/integration/test_preference_key_migration.py delete mode 100644 tests/unit_tests/components/mqtt/__init__.py delete mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..73b4f3e5bd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml deleted file mode 100644 index a9b01fc2d2..0000000000 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ /dev/null @@ -1,35 +0,0 @@ -esphome: - name: host-pref-key-migration - -host: -api: -logger: - -switch: - - platform: template - id: test_switch_restore - name: Test Switch - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - -number: - - platform: template - id: test_number_restore - name: Test Number - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0 - max_value: 100 - step: 0.5 - -text: - - platform: template - id: test_text_restore - name: Test Text - mode: text - optimistic: true - restore_value: true - initial_value: fallback - min_length: 0 - max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..f835bee3bc 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) -def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: - """Write preference entries, replacing the file's contents. - - Returns the path that was written. - """ - payload = b"" - for key, data in entries.items(): - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - return write_host_prefs(device_name, {key: data}) + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + path = host_prefs_path(device_name) + path.parent.mkdir(parents=True, exist_ok=True) + payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py deleted file mode 100644 index e7f699bb12..0000000000 --- a/tests/integration/test_preference_key_migration.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration test for entity preference key migration. - -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. - -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data -""" - -from __future__ import annotations - -import socket -import struct - -from aioesphomeapi import ( - NumberInfo, - NumberState, - SwitchInfo, - SwitchState, - TextInfo, - TextState, -) -import pytest - -from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id - -from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client -from .host_prefs import clear_host_prefs, write_host_prefs -from .state_utils import InitialStateHelper, require_entity -from .types import CompileFunction, ConfigWriter - -DEVICE_NAME = "host-pref-key-migration" - -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") - -# template_text salts its key with the length limits and pattern hash; this must -# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, -# no pattern configured) -TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF - -# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes -TEXT_MAX_LENGTH = 20 - - -def text_pref_payload(value: str) -> bytes: - """Build the length-prefixed buffer TextSaver stores for a value.""" - data = value.encode("utf-8") - assert len(data) <= TEXT_MAX_LENGTH - return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) - - -@pytest.mark.asyncio -async def test_preference_key_migration( - yaml_config: str, - write_yaml_config: ConfigWriter, - compile_esphome: CompileFunction, - reserved_tcp_port: tuple[int, socket.socket], -) -> None: - """Test that preferences stored under the old key survive the upgrade.""" - port, port_socket = reserved_tcp_port - - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY - - # Write and compile once - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - - # Release the reserved port so the binary can bind to it - port_socket.close() - - async def boot_and_get_initial_states() -> tuple[ - SwitchState, NumberState, TextState - ]: - """Boot the binary and return the restored entity states.""" - async with ( - run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), - wait_and_connect_api_client(port=port) as client, - ): - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - - entities, _ = await client.list_entities_services() - switch_entity = require_entity( - entities, "test_switch", SwitchInfo, "Test Switch" - ) - number_entity = require_entity( - entities, "test_number", NumberInfo, "Test Number" - ) - text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") - - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(lambda s: None) - ) - await initial_state_helper.wait_for_initial_states() - - switch_state = initial_state_helper.initial_states[switch_entity.key] - number_state = initial_state_helper.initial_states[number_entity.key] - text_state = initial_state_helper.initial_states[text_entity.key] - assert isinstance(switch_state, SwitchState) - assert isinstance(number_state, NumberState) - assert isinstance(text_state, TextState) - return switch_state, number_state, text_state - - try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. - write_host_prefs( - DEVICE_NAME, - { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( From 990fc402fdf12fd71e0d327edc3045591617aa53 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:24:02 -0500 Subject: [PATCH 161/597] Bump bleak from 2.1.1 to 3.0.2 (#16246) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6bc8bdf74a..876b13793c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ pillow==12.3.0 resvg-py==0.3.4 freetype-py==2.5.1 jinja2==3.1.6 -bleak==2.1.1 +bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 From b05465145fb261eb3d142982fda2d4741fa49c92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 21:14:30 -0500 Subject: [PATCH 162/597] [core] Add preference key stability integration test (#18364) --- .../fixtures/preference_key_stability.yaml | 35 ++++ tests/integration/host_prefs.py | 24 ++- .../test_preference_key_stability.py | 168 ++++++++++++++++++ 3 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/preference_key_stability.yaml create mode 100644 tests/integration/test_preference_key_stability.py diff --git a/tests/integration/fixtures/preference_key_stability.yaml b/tests/integration/fixtures/preference_key_stability.yaml new file mode 100644 index 0000000000..a74bb2c7f7 --- /dev/null +++ b/tests/integration/fixtures/preference_key_stability.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-pref-key-stability + +host: +api: +logger: + +switch: + - platform: template + id: test_switch_restore + name: Test Switch + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + +number: + - platform: template + id: test_number_restore + name: Test Number + optimistic: true + restore_value: true + initial_value: 1.0 + min_value: 0 + max_value: 100 + step: 0.5 + +text: + - platform: template + id: test_text_restore + name: Test Text + mode: text + optimistic: true + restore_value: true + initial_value: fallback + min_length: 0 + max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index f835bee3bc..c7f21d8a01 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,15 +25,25 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) +def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: + """Write preference entries, replacing the file's contents. + + Returns the path that was written. + """ + payload = b"" + for key, data in entries.items(): + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - path = host_prefs_path(device_name) - path.parent.mkdir(parents=True, exist_ok=True) - payload = struct.pack(" stores a length-prefixed buffer of max_length + 1 bytes +TEXT_MAX_LENGTH = 20 + + +def text_pref_payload(value: str) -> bytes: + """Build the length-prefixed buffer TextSaver stores for a value.""" + data = value.encode("utf-8") + assert len(data) <= TEXT_MAX_LENGTH + return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) + + +@pytest.mark.asyncio +async def test_preference_key_stability( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test that preferences stored by earlier firmware are restored.""" + port, port_socket = reserved_tcp_port + + assert SWITCH_KEY != SWITCH_BETA_KEY + assert NUMBER_KEY != NUMBER_BETA_KEY + assert TEXT_KEY != TEXT_BETA_KEY + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def boot_and_get_initial_states() -> tuple[ + SwitchState, NumberState, TextState + ]: + """Boot the binary and return the restored entity states.""" + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == DEVICE_NAME + + entities, _ = await client.list_entities_services() + switch_entity = require_entity( + entities, "test_switch", SwitchInfo, "Test Switch" + ) + number_entity = require_entity( + entities, "test_number", NumberInfo, "Test Number" + ) + text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda s: None) + ) + await initial_state_helper.wait_for_initial_states() + + switch_state = initial_state_helper.initial_states[switch_entity.key] + number_state = initial_state_helper.initial_states[number_entity.key] + text_state = initial_state_helper.initial_states[text_entity.key] + assert isinstance(switch_state, SwitchState) + assert isinstance(number_state, NumberState) + assert isinstance(text_state, TextState) + return switch_state, number_state, text_state + + try: + # --- Run 1: entries under the object_id-hash keys, exactly as any + # earlier firmware wrote them. The restored states prove the key + # scheme has not drifted. + write_host_prefs( + DEVICE_NAME, + { + SWITCH_KEY: b"\x01", # bool: switch was ON + NUMBER_KEY: struct.pack(" Date: Fri, 14 Aug 2026 00:22:22 -0500 Subject: [PATCH 163/597] [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) --- esphome/config_validation.py | 4 ++++ esphome/util.py | 14 ++++++++++++++ tests/unit_tests/test_config_validation.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From 617e2ec1e051f181ca7892966c2be34c46e2e806 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Fri, 14 Aug 2026 07:23:34 +0200 Subject: [PATCH 164/597] [core] fix PYTHONPATH leak (#18360) --- esphome/espidf/toolchain.py | 2 ++ esphome/framework_helpers.py | 2 ++ tests/unit_tests/test_espidf_toolchain.py | 15 +++++++++++++++ tests/unit_tests/test_framework_helpers.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") run_command(["cmd"], cwd=str(tmp_path)) From d72bab79d7363c81d849078556f1de71953cf60c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Aug 2026 21:09:01 -0500 Subject: [PATCH 165/597] [web_server_base] Stop deleting the web server on captive portal teardown (#18324) --- .../web_server/ota/ota_web_server.cpp | 2 +- .../web_server_base/web_server_base.h | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 9812714ec0..95763e2daf 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() { return; } - // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed + // The handler lives for the life of the process; WebServerBase never destroys its server base->add_handler(new OTARequestHandler(this)); // NOLINT } diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c647a13b50..94579de70f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler { class WebServerBase final { public: + // The AsyncWebServer is created once and intentionally never deleted: on Arduino + // platforms ESPAsyncWebServer owns its registered handlers, so destroying it would + // also destroy live components (e.g. the captive portal) out from under us. + // init()/deinit() refcount users and start/stop the listener; handlers are + // registered once at creation and survive listener restarts. void init() { - if (this->initialized_) { - this->initialized_++; + this->initialized_++; + if (this->server_ != nullptr) { + if (this->initialized_ == 1) { + // Restart the listener after a previous deinit() + this->server_->begin(); + } return; } this->server_ = new AsyncWebServer(this->port_); @@ -126,14 +135,13 @@ class WebServerBase final { for (auto *handler : this->handlers_) this->server_->addHandler(handler); - - this->initialized_++; } void deinit() { + if (this->initialized_ == 0) + return; // unbalanced deinit() this->initialized_--; if (this->initialized_ == 0) { - delete this->server_; - this->server_ = nullptr; + this->server_->end(); } } AsyncWebServer *get_server() const { return this->server_; } From 7c07fb48c5cbf1c5ca7c8ba04e033d2d0eb14568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:22:48 -0500 Subject: [PATCH 166/597] [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) --- .../components/ble_device_base/__init__.py | 20 ++- .../components/esp32_ble_tracker/__init__.py | 71 +++++++++- .../test_scan_parameter_validation.py | 7 +- .../esp32_ble_tracker/__init__.py | 0 .../test_scan_window_default.py | 122 ++++++++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/__init__.py create mode 100644 tests/component_tests/esp32_ble_tracker/test_scan_window_default.py diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 4da7d48882..15a8b08139 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_INTERVAL, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, ID, KEY_CORE +from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] @@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType: return config +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + + def scan_parameters_schema( interval_default: str, *, - window_default: str = "30ms", + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, ) -> cv.All: """Build the scan_parameters value schema shared by all BLE trackers. interval_default and window_default are per chip (e.g. esp32 320/30 ms, bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; - LN882H's SDK recommends 100/50 ms). The `active` option (default on) is - unconditional: active scanning is part of the tracker contract — every - current proxy client assumes it, so a passive-only tracker must not share - this schema. + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. """ schema = { cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 634b8c3bef..28c8c7fcf1 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import copy +from dataclasses import dataclass import logging from esphome import automation @@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, + idf_version, request_bluetooth, request_software_coexistence, ) @@ -35,10 +38,12 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType +DOMAIN = "esp32_ble_tracker" + AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] @@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + + +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. + """ + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + params = config[CONF_SCAN_PARAMETERS] + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. -SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms") +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default +) # Codegen helpers are owned by ble_device_base; kept under the historical names # here for the components that import them from this module. @@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, ) diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index 2549125a43..3774d990d3 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None: def test_esp32_defaults_are_valid() -> None: - """esp32 pins the ESP-IDF reference rate and exposes active (default on).""" + """esp32 pins the ESP-IDF reference rate and exposes active (default on). + + Without wifi loaded, the conditional window default falls back to the + historical 30 ms; the wifi-aware resolution is covered by the + esp32_ble_tracker component tests. + """ config = ESP32_SCHEMA({}) assert to_ble_units(config["interval"]) == 512 assert to_ble_units(config["window"]) == 48 diff --git a/tests/component_tests/esp32_ble_tracker/__init__.py b/tests/component_tests/esp32_ble_tracker/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py new file mode 100644 index 0000000000..8a25f488fa --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -0,0 +1,122 @@ +"""Tests for the esp32_ble_tracker conditional scan window default. + +The scan window default depends on wifi coexistence and the IDF version: +IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the +configured window (espressif/esp-idf#18931), so on fixed versions the +historical 30 ms default would only listen 9.4 % of the time and miss most +advertisements. With the coexistence arbiter compiled in on a fixed IDF, the +window instead defaults to the interval, as Espressif recommends; without the +arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base import to_ble_units +from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_ble_tracker import ( + CONF_SOFTWARE_COEXISTENCE, + CONFIG_SCHEMA, +) +from esphome.const import CONF_INTERVAL, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +from ..types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32( + set_core_config: SetCoreConfigCallable, +) -> Callable[..., None]: + """Stage an esp32 build with a given IDF version and wifi presence.""" + + def stage(idf: str, *, wifi: bool) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + if wifi: + # Makes cv.OnlyWith default software_coexistence to True, exactly + # as a real config with wifi: does. + CORE.loaded_integrations.add("wifi") + + return stage + + +def _scan_params(config: ConfigType) -> ConfigType: + return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS] + + +@pytest.mark.parametrize( + ("idf", "config", "expected_units"), + [ + ("5.5.5", {}, 512), # first fixed version, default 320 ms interval + ("6.0.1", {}, 512), # any newer version behaves the same + # Follows a user-set interval. + ("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600), + ], +) +def test_wifi_on_fixed_idf_defaults_window_to_interval( + stage_esp32: Callable[..., None], + idf: str, + config: ConfigType, + expected_units: int, +) -> None: + """With wifi coexistence on a fixed IDF, the window defaults to the interval.""" + stage_esp32(idf, wifi=True) + params = _scan_params(config) + assert params[CONF_WINDOW] == params[CONF_INTERVAL] + assert to_ble_units(params[CONF_WINDOW]) == expected_units + + +@pytest.mark.parametrize( + ("idf", "wifi", "config"), + [ + # Buggy IDF over-scans anyway; keep the 30 ms default. + ("5.5.4", True, {}), + # No wifi (e.g. ethernet) means no radio contention. + ("5.5.5", False, {}), + # Coexistence disabled: no arbiter, so a full-duty scan would starve + # wifi outright. + ("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}), + ], +) +def test_30ms_default_kept( + stage_esp32: Callable[..., None], + idf: str, + wifi: bool, + config: ConfigType, +) -> None: + stage_esp32(idf, wifi=wifi) + assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48 + + +@pytest.mark.parametrize("window", ["60ms", "30ms"]) +def test_explicit_window_is_never_touched( + stage_esp32: Callable[..., None], window: str +) -> None: + """A user-set window wins over the conditional default. + + The explicit 30 ms case matters: it is indistinguishable from the + defaulted value by inspection, so the defaulted flag must separate them. + """ + stage_esp32("5.5.5", wifi=True) + params = _scan_params({"scan_parameters": {"window": window}}) + assert to_ble_units(params[CONF_WINDOW]) == to_ble_units( + cv.positive_time_period(window) + ) + + +def test_short_interval_without_window_still_rejected( + stage_esp32: Callable[..., None], +) -> None: + """The provisional 30 ms default validates against the interval as before.""" + stage_esp32("5.5.5", wifi=True) + with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"): + _scan_params({"scan_parameters": {"interval": "20ms"}}) From 236ff33a09e4865c4ee88a0aae711d71955640f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:27:01 -0500 Subject: [PATCH 167/597] [esp32_ble] Silence spurious warnings for local key GAP events (#18359) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 16501ef3b2..e2d79173ff 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: From 1c3a67b5e815617abf419721525c182d302931aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:30:53 -0500 Subject: [PATCH 168/597] [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) --- .../wifi/wifi_component_esp8266.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 719a276bf9..acaa94b13c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() { https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251 */ #undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr() +#undef netif_set_down // need to call lwIP-v1.4 netif_set_down() extern "C" { struct netif *eagle_lwip_getif(int netif_index); void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw); +void netif_set_down(struct netif *netif); }; + +// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP +// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in +// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308). +static void sta_netif_down() { + struct netif *iface = eagle_lwip_getif(STATION_IF); + if (iface != nullptr) + netif_set_down(iface); +} #endif bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { @@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } global_wifi_component->error_from_callback_ = true; +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; #endif @@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif wifi_station_disconnect(); global_wifi_component->error_from_callback_ = true; } @@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_disconnect_() { bool ret = true; // Only call disconnect if interface is up - if (wifi_get_opmode() & WIFI_STA) + if (wifi_get_opmode() & WIFI_STA) { +#if LWIP_VERSION_MAJOR != 1 + sta_netif_down(); +#endif ret = wifi_station_disconnect(); + } station_config conf{}; memset(&conf, 0, sizeof(conf)); ETS_UART_INTR_DISABLE(); From 4f3153375a7acb307de0ce6deef708975fff58cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:06 -0500 Subject: [PATCH 169/597] [ota] Retry uploads that fail from network errors (#18332) --- esphome/espota2.py | 158 ++++++++++--- tests/unit_tests/test_espota2.py | 382 +++++++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 47 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index fa15c1dda2..61e897f601 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib import gzip import hashlib import io @@ -8,7 +9,6 @@ import logging from pathlib import Path import secrets import socket -import sys import time from typing import Any @@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset( UPLOAD_BLOCK_SIZE = 8192 UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 +# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time +# to clean up a half-open connection (its handshake watchdog runs at 20s) before it +# accepts a new one, so wait between attempts instead of failing the upload outright. +# Every resolved address is tried once, and this many extra attempts are shared +# across the addresses on top of that. +EXTRA_UPLOAD_ATTEMPTS = 2 +UPLOAD_RETRY_DELAY = 5.0 + _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) @@ -171,6 +179,23 @@ class OTAError(EsphomeError): pass +class OTANetworkError(OTAError): + """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" + + +def _committed_error(err: OTANetworkError) -> OTAError: + """Wrap a network failure that happened once the device had the full image. + + Past that point the device commits and reboots on its own, so the failure + must not be retried; a re-upload could flash a device that already updated. + """ + return OTAError( + f"{err} (the device may have already committed the update and " + f"be rebooting; check whether it comes back with the new " + f"firmware before uploading again)" + ) + + def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: @@ -209,19 +234,22 @@ def receive_exactly( try: data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg} response: {err}") from err + raise OTANetworkError(f"receiving {msg} response: {err}") from err try: check_error(data, expect) except OTAError as err: sock.close() - raise OTAError(f"receiving {msg}: {err}") from err + # type(err) preserves OTANetworkError vs OTAError so callers can tell + # retryable network failures from device-reported errors; subclasses + # must accept a single message argument + raise type(err)(f"receiving {msg}: {err}") from err while len(data) < amount: try: data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: - raise OTAError(f"receiving {msg}: {err}") from err + raise OTANetworkError(f"receiving {msg}: {err}") from err return data @@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None # accept-any-response reads (e.g. feature negotiation, auth nonces) would be # silently passed through and surface later as cryptic decode/timeout failures. if not data: - raise OTAError( + raise OTANetworkError( "Device closed connection without responding. " "This may indicate the device ran out of memory, " "a network issue, or the connection was interrupted." @@ -274,7 +302,7 @@ def send_check( sock.sendall(data) except OSError as err: - raise OTAError(f"sending {msg}: {err}") from err + raise OTANetworkError(f"sending {msg}: {err}") from err def perform_ota( @@ -306,7 +334,7 @@ def perform_ota( send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.debug("Device support OTA version: %s", version) + _LOGGER.info("Connection established; device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -417,6 +445,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) + _LOGGER.info("Handshake complete") + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures sock.settimeout(90.0) @@ -449,21 +479,43 @@ def perform_ota( offset = 0 progress = ProgressBar("Uploading") - while True: - chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] - if not chunk: - break - offset += len(chunk) + try: + while True: + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] + if not chunk: + break + offset += len(chunk) + + try: + sock.sendall(chunk) + except OSError as err: + # A send failure can hide an error byte the device reported + # just before dropping the connection; surface that as the + # real, non-retryable cause when it is available + try: + sock.settimeout(1.0) + check_error(recv_decode(sock, 1), None) + except (OSError, OTANetworkError) as probe_err: + _LOGGER.debug( + "No device error behind the send failure: %s", probe_err + ) + raise OTANetworkError(f"sending data: {err}") from err - try: - sock.sendall(chunk) if version >= OTA_VERSION_2_0: - receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) - except OSError as err: - sys.stderr.write("\n") - raise OTAError(f"sending data: {err}") from err + try: + receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK) + except OTANetworkError as err: + if offset < upload_size: + raise + # The device already had the complete image when this ack + # was lost, so it may be committing; do not retry + raise _committed_error(err) from err - progress.update(offset / upload_size) + progress.update(offset / upload_size) + except OTAError: + # Terminate the progress bar line before the error is logged + progress.done() + raise progress.done() # Enable nodelay for last checks @@ -472,11 +524,25 @@ def perform_ota( _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) - receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) - receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) - send_check(sock, RESPONSE_OK, "end acknowledgement") + # Once the device has the complete image it commits the update and + # reboots on its own; the exact commit point is not observable from + # here, so treat everything past the data phase as non-retryable. A + # re-upload could flash a device that already updated successfully. + try: + receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) + receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) + except OTANetworkError as err: + raise _committed_error(err) from err - _LOGGER.info("OTA successful") + try: + send_check(sock, RESPONSE_OK, "end acknowledgement") + except OTANetworkError as err: + # The device treats a missing end acknowledgement as non-fatal and is + # already rebooting into the new firmware, so the update succeeded + _LOGGER.warning("Failed sending end acknowledgement: %s", err) + _LOGGER.info("OTA successful (end acknowledgement not delivered)") + else: + _LOGGER.info("OTA successful") # Do not connect logs until it is fully on time.sleep(1) @@ -510,8 +576,33 @@ def run_ota_impl_( ) raise OTAError(err) from err - for r in res: - af, socktype, _, _, sa = r + if not res: + _LOGGER.error("No addresses to connect to for %s", remote_host) + return 1, None + + # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries + # are shared across the addresses, cycling through them. Wait before an + # attempt when the previous one actually reached the device, or when + # revisiting an address, so a flaky link can recover and the device can + # clean up a half-open connection (its handshake watchdog runs at 20s); + # moving on to the next address family stays immediate. Known limitation: + # a silent mid-transfer drop with no reset can wedge the device until its + # 90s data timeout, which outlasts this budget; the retries target the + # common failures where the device resets or closes the link promptly. + total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS + last_error = "" + reached_device = False + for attempt in range(total_attempts): + af, socktype, _, _, sa = res[attempt % len(res)] + if reached_device or attempt >= len(res): + _LOGGER.info( + "Retrying in %.0f seconds (attempt %d of %d)...", + UPLOAD_RETRY_DELAY, + attempt + 1, + total_attempts, + ) + time.sleep(UPLOAD_RETRY_DELAY) + reached_device = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -519,23 +610,30 @@ def run_ota_impl_( sock.connect(sa) except OSError as err: sock.close() - _LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) + last_error = f"connecting to {sa[0]} failed: {err}" continue _LOGGER.info("Connected to %s", sa[0]) - with Path(filename).open("rb") as file_handle: + reached_device = True + with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: perform_ota(sock, password, file_handle, filename, ota_type) + except OTANetworkError as err: + # Transient network failure; retry + last_error = str(err) + _LOGGER.warning("%s", last_error) + continue except OTAError as err: + # Device-reported error (wrong password, wrong flash size, ...); + # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) return 1, None - finally: - sock.close() # Successfully uploaded to sa[0] return 0, sa[0] - _LOGGER.error("Connection failed.") + _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) return 1, None diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 9413fbcf29..db4a4b1117 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -44,13 +44,17 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time() -> Generator[None]: +def mock_sleep() -> Generator[Mock]: + """Mock time.sleep so delays don't slow down tests.""" + with patch("time.sleep") as mock: + yield mock + + +@pytest.fixture +def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), - ): + with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): yield @@ -79,6 +83,28 @@ def mock_resolve_ip() -> Generator[Mock]: yield mock +DUAL_STACK_SA6 = ("2001:db8::1", 3232, 0, 0) +DUAL_STACK_SA4 = ("192.168.1.100", 3232) + + +@pytest.fixture +def mock_resolve_ip_dual(mock_resolve_ip: Mock) -> Mock: + """Make resolve_ip_address return an IPv6 and an IPv4 address.""" + mock_resolve_ip.return_value = [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA6), + (socket.AF_INET, socket.SOCK_STREAM, 0, "", DUAL_STACK_SA4), + ] + return mock_resolve_ip + + +@pytest.fixture +def firmware_file(tmp_path: Path) -> Path: + """Create a firmware file on disk for run_ota_impl_ tests.""" + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"firmware content") + return firmware + + @pytest.fixture def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" @@ -137,9 +163,11 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="receiving auth:.*Authentication invalid" - ): + ) as exc_info: espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + # Device-reported errors must stay plain OTAError, not the retryable kind + assert not isinstance(exc_info.value, espota2.OTANetworkError) mock_socket.close.assert_called_once() @@ -147,10 +175,30 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") - with pytest.raises(espota2.OTAError, match="receiving test response"): + with pytest.raises(espota2.OTANetworkError, match="receiving test response"): espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) +def test_receive_exactly_mid_read_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors after the first byte.""" + mock_socket.recv.side_effect = [b"\x00", OSError("Connection reset")] + + with pytest.raises(espota2.OTANetworkError, match="receiving test:"): + espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + +def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) -> None: + """Test receive_exactly raises OTANetworkError when the device closes the connection.""" + mock_socket.recv.return_value = b"" + + with pytest.raises( + espota2.OTANetworkError, match="Device closed connection without responding" + ): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + mock_socket.close.assert_called_once() + + @pytest.mark.parametrize( ("error_code", "expected_msg"), [ @@ -227,15 +275,15 @@ def test_check_error_unexpected_response() -> None: def test_check_error_empty_data() -> None: - """Test check_error raises error when device closes connection without responding.""" + """Test check_error raises the retryable OTANetworkError when the device closes the connection.""" with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error([], [espota2.RESPONSE_OK]) # Also test with empty bytes with pytest.raises( - espota2.OTAError, match="Device closed connection without responding" + espota2.OTANetworkError, match="Device closed connection without responding" ): espota2.check_error(b"", [espota2.RESPONSE_OK]) @@ -530,6 +578,144 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, None, mock_file, "test.bin") +def _no_auth_handshake(version: int) -> list[bytes]: + """Recv responses for a handshake without auth, up to the MD5 check.""" + return [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([version]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA raises the retryable OTANetworkError when sending a chunk fails.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Probe for a pending error byte fails too + ] + # Sends before the data phase: magic bytes, features, binary size, MD5; + # fail on the fifth sendall, the first firmware chunk + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises(espota2.OTANetworkError, match="sending data:"): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_chunk_send_error_surfaces_device_error( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a device error byte pending behind a send failure becomes the cause.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_ERROR_WRITING_FLASH]), # Reason the device closed + ] + mock_socket.sendall.side_effect = [None] * 4 + [OSError("Broken pipe")] + + with pytest.raises( + espota2.OTAError, match="Writing OTA data to flash memory failed" + ) as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device-reported error is not retryable + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_final_chunk_ack_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a lost ack for the final chunk is not retried.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the only (final) chunk is lost + ] + + with pytest.raises(espota2.OTAError, match="receiving chunk result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device already had the whole image, so it may be committing + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_intermediate_chunk_ack_failure_retryable( + mock_socket: Mock, +) -> None: + """Test a lost ack for a non-final chunk stays retryable.""" + # Two chunks: the firmware is larger than one upload block + big_file = io.BytesIO(b"x" * (espota2.UPLOAD_BLOCK_SIZE + 1)) + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_2_0), + OSError("Connection reset"), # Ack for the first of two chunks is lost + ] + + with pytest.raises(espota2.OTANetworkError, match="receiving chunk result"): + espota2.perform_ota(mock_socket, None, big_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_post_commit_failure_not_retryable( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a network failure after the device committed is a plain OTAError.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + OSError("Connection reset"), # Connection lost waiting for end result + ] + + with pytest.raises(espota2.OTAError, match="receiving update end result") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # Must not be the retryable kind; the device is already rebooting + assert not isinstance(exc.value, espota2.OTANetworkError) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_mismatch_not_marked_committed( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test an MD5 mismatch keeps its own message and stays non-retryable.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_ERROR_MD5_MISMATCH]), # Device aborted the update + ] + + with pytest.raises(espota2.OTAError, match="MD5 code mismatch") as exc: + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + # The device aborted without committing, so the message must not claim + # the update may have been installed, and the error must not be retried + assert not isinstance(exc.value, espota2.OTANetworkError) + assert "committed" not in str(exc.value) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_end_ack_send_failure_is_success( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test a send failure on the final acknowledgement does not fail the OTA.""" + mock_socket.recv.side_effect = [ + *_no_auth_handshake(espota2.OTA_VERSION_1_0), + bytes([espota2.RESPONSE_RECEIVE_OK]), # Device received everything + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update committed + ] + # Sends: magic bytes, features, binary size, MD5, one firmware chunk; + # fail on the sixth sendall, the end acknowledgement + mock_socket.sendall.side_effect = [None] * 5 + [OSError("Broken pipe")] + + # Must not raise; the device treats a missing acknowledgement as non-fatal + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + + assert mock_socket.sendall.call_count == 6 + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock @@ -564,21 +750,183 @@ def test_run_ota_impl_successful( @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") -def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: - """Test run_ota_impl_ when connection fails.""" +def test_run_ota_impl_connection_failed( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries when connection fails and eventually gives up.""" mock_socket.connect.side_effect = OSError("Connection refused") - # Create a real firmware file - firmware_file = tmp_path / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 assert result_host is None - mock_socket.close.assert_called_once() + # A single address gets the whole attempt budget, with a delay before + # each revisit + assert mock_socket.connect.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_socket.close.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connect_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ succeeds when a retry connects after a failed attempt.""" + mock_socket.connect.side_effect = [OSError("Connection timed out"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_socket.connect.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_retry_succeeds( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ retries after a network error during the upload.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("receiving features: Device closed connection"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + assert mock_perform_ota.call_count == 2 + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_network_error_exhausts_attempts( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ gives up after all attempts hit network errors.""" + mock_perform_ota.side_effect = espota2.OTANetworkError("sending data: broken pipe") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + assert mock_perform_ota.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + 1 + assert mock_sleep.call_count == espota2.EXTRA_UPLOAD_ATTEMPTS + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_multiple_addresses_cycle( + mock_socket: Mock, firmware_file: Path, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ visits every address and cycles for the retries.""" + mock_socket.connect.side_effect = OSError("No route to host") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + # Each address is visited once, then the EXTRA_UPLOAD_ATTEMPTS spare + # attempts cycle back through them; the budget is shared, not per address + assert mock_socket.connect.call_args_list == [ + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + call(DUAL_STACK_SA6), + call(DUAL_STACK_SA4), + ] + # No connect ever reached the device, so the delay only applies before + # the revisits + assert mock_sleep.call_count == 2 + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_second_address_succeeds_without_delay( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ falls through to the next address with no pause.""" + mock_socket.connect.side_effect = [OSError("No route to host"), None] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + mock_sleep.assert_not_called() + mock_perform_ota.assert_called_once() + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip_dual") +def test_run_ota_impl_pauses_after_reaching_device( + mock_socket: Mock, + firmware_file: Path, + mock_perform_ota: Mock, + mock_sleep: Mock, +) -> None: + """Test run_ota_impl_ pauses before the next address once the device was reached.""" + mock_perform_ota.side_effect = [ + espota2.OTANetworkError("sending data: connection reset"), + None, + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 0 + assert result_host == "192.168.1.100" + # The first attempt reached the device, so the next one waits first even + # though it targets a fresh address + mock_sleep.assert_called_once_with(espota2.UPLOAD_RETRY_DELAY) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_device_error_not_retried( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails immediately on a device-reported error.""" + mock_perform_ota.side_effect = espota2.OTAError( + "Authentication invalid. Is the password correct?" + ) + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_perform_ota.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_ota_impl_no_addresses( + firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock +) -> None: + """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + mock_resolve_ip.return_value = [] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_sleep.assert_not_called() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 02c1810c3ad388b37025fd65307fd86dbf3e66a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 13:31:19 -0500 Subject: [PATCH 170/597] [core] Load component aliases from a generated registry (#18335) --- .github/workflows/ci.yml | 1 + esphome/component_aliases.py | 10 ++++++ esphome/loader.py | 61 +++++++++++++-------------------- script/build_alias_registry.py | 59 +++++++++++++++++++++++++++++++ tests/unit_tests/test_loader.py | 28 +++++++++++++++ 5 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 esphome/component_aliases.py create mode 100755 script/build_alias_registry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e695bb46b..b603e68ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,7 @@ jobs: . venv/bin/activate script/ci-custom.py script/build_codeowners.py --check + script/build_alias_registry.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check script/generate-rp2-boards.py --check diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} diff --git a/esphome/loader.py b/esphome/loader.py index 7a659aa0a8..f994f0c5eb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -269,10 +269,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: # If `domain` is the legacy name of a renamed component, redirect to the # canonical module so the rest of the loader (and every caller of # `get_component(legacy)`) transparently sees the new component. - alias_map = _get_alias_map() - if domain in alias_map: - canonical = alias_map[domain] - manif = _lookup_module(canonical, exception) + alias_meta = get_alias_metadata().get(domain) + if alias_meta is not None: + manif = _lookup_module(alias_meta.canonical, exception) if manif is not None: _COMPONENT_CACHE[domain] = manif return manif @@ -329,8 +328,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # --------------------------------------------------------------------------- # # A component can declare ``ALIASES = ["legacy_name"]`` (and optionally -# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two -# integrations are then wired up automatically: +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run +# ``script/build_alias_registry.py`` to regenerate +# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry +# is stale). Two integrations are then wired up automatically: # # 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) # intercepts ``esphome.components.``/``....`` @@ -344,13 +345,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non # dependency checks, schema validation and codegen all see only the # canonical name. # -# Both lookups are populated by ``_build_alias_map``, which **AST-parses** -# every component's ``__init__.py`` rather than importing it. That keeps the -# cost low: scanning ~400 components on disk takes ~5 ms instead of the -# multi-second cost of executing every component's import side-effects. +# Both lookups read the checked-in registry in ``esphome.component_aliases`` +# (generated by ``script/build_alias_registry.py``, verified in CI), so no +# component-directory scan happens at runtime. ``_build_alias_map`` below is +# the generator's scan implementation; it **AST-parses** each component's +# ``__init__.py`` rather than importing it. -_ALIAS_MAP_CACHE: dict[str, str] | None = None _ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None @@ -367,31 +368,17 @@ class AliasMeta: removal_version: str | None -def _ensure_alias_caches() -> None: - """Populate both alias caches from a single directory scan. - - ``_build_alias_map`` returns both maps together, so building them in one - shot avoids scanning every component's ``__init__.py`` twice when a run - needs both the canonical map (loader) and the metadata map (config - pre-pass). - """ - global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE - if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: - _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() - - -def _get_alias_map() -> dict[str, str]: - """Return the legacy-name → canonical-name map, building it lazily.""" - _ensure_alias_caches() - return _ALIAS_MAP_CACHE - - def get_alias_metadata() -> dict[str, AliasMeta]: - """Return the legacy-name → :class:`AliasMeta` map (cached). + """Return the legacy-name → :class:`AliasMeta` map, built lazily from + the generated registry.""" + global _ALIAS_META_CACHE # noqa: PLW0603 + if _ALIAS_META_CACHE is None: + from esphome.component_aliases import COMPONENT_ALIASES - Used by the YAML pre-pass to format a per-alias deprecation warning. - """ - _ensure_alias_caches() + _ALIAS_META_CACHE = { + alias: AliasMeta(canonical=canonical, removal_version=removal_version) + for alias, (canonical, removal_version) in COMPONENT_ALIASES.items() + } return _ALIAS_META_CACHE @@ -537,11 +524,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder): # least three parts, so ``parts[2]`` (the domain) always exists. parts = fullname.split(".") domain = parts[2] - alias_map = _get_alias_map() - if domain not in alias_map: + alias_meta = get_alias_metadata().get(domain) + if alias_meta is None: return None - parts[2] = alias_map[domain] + parts[2] = alias_meta.canonical canonical_fullname = ".".join(parts) try: canonical_module = importlib.import_module(canonical_fullname) diff --git a/script/build_alias_registry.py b/script/build_alias_registry.py new file mode 100755 index 0000000000..e007c075eb --- /dev/null +++ b/script/build_alias_registry.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate esphome/component_aliases.py from component ALIASES declarations. + +Run without arguments to regenerate the registry; ``--check`` (run in CI) +verifies it is up to date. +""" + +import argparse +from pathlib import Path +import sys + +# The root directory of the repo +root = Path(__file__).parent.parent +# Make the repo's esphome package win over any installed copy +sys.path.insert(0, str(root)) + +from esphome.helpers import write_file_if_changed # noqa: E402 +from esphome.loader import _build_alias_map # noqa: E402 + +parser = argparse.ArgumentParser() +parser.add_argument( + "--check", + help="Check if the alias registry is up to date.", + action="store_true", +) +args = parser.parse_args() + +registry_file = root / "esphome" / "component_aliases.py" + +HEADER = '''"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { +''' + +# _build_alias_map scans the real component tree and already rejects +# duplicate and shadowing aliases with an EsphomeError. +_, alias_meta = _build_alias_map() + +lines = [HEADER] +for alias, meta in sorted(alias_meta.items()): + removal = f'"{meta.removal_version}"' if meta.removal_version else "None" + lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n') +lines.append("}\n") +content = "".join(lines) + +if args.check: + if registry_file.read_text(encoding="utf-8") != content: + print("Component alias registry is not up to date.") + print("Please run `script/build_alias_registry.py`") + sys.exit(1) + print("Component alias registry is up to date") +else: + write_file_if_changed(registry_file, content) + print(f"Wrote {registry_file}") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 41dd462678..74515e9d4c 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( AliasMeta, ComponentManifest, @@ -481,6 +482,33 @@ def test_real_alias_map_includes_rp2040() -> None: assert meta["rp2040"].removal_version == "2027.7.0" +def test_alias_registry_matches_component_tree() -> None: + """The checked-in registry must match a live scan of the component tree.""" + _, meta_map = _build_alias_map() + expected = { + alias: (meta.canonical, meta.removal_version) + for alias, meta in meta_map.items() + } + assert expected == COMPONENT_ALIASES, ( + "esphome/component_aliases.py is out of date; " + "run script/build_alias_registry.py" + ) + + +def test_alias_map_built_from_registry() -> None: + """The runtime alias map comes from the generated registry, not a scan.""" + with ( + patch( + "esphome.component_aliases.COMPONENT_ALIASES", + {"legacy": ("modern", "2099.1.0")}, + ), + patch("esphome.loader._ALIAS_META_CACHE", None), + ): + assert get_alias_metadata() == { + "legacy": AliasMeta(canonical="modern", removal_version="2099.1.0") + } + + def test_get_component_resolves_alias() -> None: """``get_component('rp2040')`` should return the rp2 manifest — every caller of the loader (dep checker, schema validator, codegen) hits From add18d4e351f582757781aa92f68eaa534ea8748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Aug 2026 20:06:48 -0500 Subject: [PATCH 171/597] [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) --- esphome/components/api/api_connection.cpp | 6 +- esphome/components/infrared/infrared.cpp | 8 +- esphome/components/mqtt/__init__.py | 63 --- esphome/components/prometheus/__init__.py | 6 - .../radio_frequency/radio_frequency.cpp | 8 +- .../template/text/template_text.cpp | 20 +- .../components/template/text/template_text.h | 15 +- esphome/core/application.h | 8 +- esphome/core/entity_base.cpp | 52 +-- esphome/core/entity_base.h | 80 ++-- esphome/core/entity_helpers.py | 190 ++++---- esphome/core/helpers.h | 29 +- esphome/core/preference_backend.h | 14 +- esphome/core/preferences.cpp | 25 - esphome/core/preferences.h | 12 - esphome/helpers.py | 15 +- tests/integration/entity_utils.py | 27 +- .../fixtures/fnv1_hash_object_id.yaml | 32 -- .../fixtures/multi_device_preferences.yaml | 21 +- .../fixtures/preference_key_migration.yaml | 35 -- tests/integration/host_prefs.py | 24 +- tests/integration/test_fnv1_hash_object_id.py | 4 - .../test_object_id_api_verification.py | 10 +- ...t_object_id_friendly_name_no_mac_suffix.py | 4 +- .../test_object_id_no_friendly_name.py | 6 +- .../test_preference_key_migration.py | 165 ------- tests/unit_tests/components/mqtt/__init__.py | 0 .../mqtt/test_object_id_conflicts.py | 239 ---------- tests/unit_tests/core/test_entity_helpers.py | 432 ++++++++++-------- .../object_id_conflict_mqtt.yaml | 22 - .../object_id_conflict_no_mqtt.yaml | 15 - .../test_preference_hash_stability.py | 34 +- 32 files changed, 489 insertions(+), 1132 deletions(-) delete mode 100644 esphome/core/preferences.cpp delete mode 100644 tests/integration/fixtures/preference_key_migration.yaml delete mode 100644 tests/integration/test_preference_key_migration.py delete mode 100644 tests/unit_tests/components/mqtt/__init__.py delete mode 100644 tests/unit_tests/components/mqtt/test_object_id_conflicts.py delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml delete mode 100644 tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d05f98d03b..73b4f3e5bd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() { uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); #ifdef USE_DEVICES msg.device_id = entity->get_device_id(); #endif @@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { // Set common fields that are shared by all entity types - msg.key = entity->get_entity_key(); + msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { msg.name = entity->get_name(); @@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_entity_key(); + msg.key = camera::Camera::instance()->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 288b1e5c40..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) { // Forward received IR data to API server #if defined(USE_API) && defined(USE_IR_RF) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 713969ab88..98ca23b60b 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -63,7 +63,6 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts from esphome.types import ConfigType DEPENDENCIES = ["network"] @@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All( ) -# Platforms whose MQTT components subscribe to an object_id-derived command topic. -# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus -# text, whose MQTT component subscribes a command topic that cannot be overridden. -_COMMAND_TOPIC_PLATFORMS = frozenset( - { - "alarm_control_panel", - "button", - "climate", - "cover", - "datetime", - "fan", - "light", - "lock", - "number", - "select", - "switch", - "text", - "update", - "valve", - } -) - - -# Platforms whose MQTT components derive extra sub-topics (position/command, -# mode/command, speed/command, ...) from the object_id, each with its own config -# key; custom state and command topics cannot exempt them from conflicting. -_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"}) - - -def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool: - """Check whether more than one entity actually uses an object_id-derived topic. - - An empty topic_prefix disables default topics entirely, custom state and - command topics avoid the default topics, and disabling discovery (globally - or per entity) avoids the discovery config topic. - """ - if config[CONF_TOPIC_PREFIX]: - platform = entities[0].platform - if platform in _SUB_TOPIC_PLATFORMS: - return True - if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1: - return True - if ( - platform in _COMMAND_TOPIC_PLATFORMS - and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1 - ): - return True - if not config[CONF_DISCOVERY]: - return False - discovery_entities = sum( - entity.config.get(CONF_DISCOVERY, True) for entity in entities - ) - return discovery_entities > 1 - - -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "mqtt builds default topics and discovery topics from the entity object_id, " - "which is the name converted to ASCII", - conflict_filter=_topics_conflict, -) - - def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 0a69160fc1..cc1541ce80 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -3,7 +3,6 @@ from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL -from esphome.core.entity_helpers import validate_no_object_id_conflicts from esphome.cpp_types import EntityBase AUTO_LOAD = ["web_server_base"] @@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema( }, ).extend(cv.COMPONENT_SCHEMA) -FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id, " - "which is the name converted to ASCII" -) - async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index fe6c6a9cb5..3e0a905737 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { // Forward received RF data to API server #if defined(USE_API) && defined(USE_RADIO_FREQUENCY) if (api::global_api_server != nullptr) { - api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(), - &data.get_raw_data()); +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index ffe11cf229..af134e6ed4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -20,14 +20,18 @@ void TemplateText::setup() { // Need std::string for pref_->setup() to fill from flash std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""}; - uint32_t extra = 0; - extra += this->traits.get_min_length() << 2; - extra += this->traits.get_max_length() << 4; - extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6; - // TextSaver::setup() picks the key for the platform and migrates old data once - uint32_t key = this->preference_key_base_() + extra; - uint32_t old_key = this->old_preference_key_base_() + extra; - this->pref_->setup(key, old_key, value); + // For future hash migration: use migrate_entity_preference_() with: + // old_key = get_preference_hash() + extra + // new_key = get_preference_hash_v2() + extra + // See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash(); +#pragma GCC diagnostic pop + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; + this->pref_->setup(key, value); if (!value.empty()) this->publish_state(value); } diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index beeea4396a..229a61d9b8 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -14,9 +14,7 @@ class TemplateTextSaverBase { public: virtual bool save(const std::string &value) { return true; } - /// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once. - /// See: https://github.com/esphome/backlog/issues/85 - virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {} + virtual void setup(uint32_t id, std::string &value) {} protected: ESPPreferenceObject pref_; @@ -47,16 +45,11 @@ template class TextSaver : public TemplateTextSaverBase { // Make the preference object. Fill the provided location with the saved data // If it is available, else leave it alone - void setup(uint32_t id, uint32_t old_id, std::string &value) override { - char temp[SZ + 1]; -#ifdef USE_PREFERENCE_KEY_LOOKUP + void setup(uint32_t id, std::string &value) override { this->pref_ = global_preferences->make_preference(id); - bool hasdata = migrate_preference(this->pref_, reinterpret_cast(temp), SZ + 1, old_id, id); -#else - // Slot-based backends keep the old key; it is only a validity tag on a positional slot - this->pref_ = global_preferences->make_preference(old_id); + + char temp[SZ + 1]; bool hasdata = this->pref_.load(&temp); -#endif if (hasdata) { size_t len = static_cast(temp[0]); diff --git a/esphome/core/application.h b/esphome/core/application.h index a18a6b31c8..a12cdc4ac8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -120,8 +120,8 @@ class Application { // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ - void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \ - obj->configure_entity_(name, entity_key, entity_fields); \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ this->plural##_.push_back(obj); \ } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ @@ -329,7 +329,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \ + if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \ (include_internal || !obj->is_internal())) \ return obj; \ } \ @@ -340,7 +340,7 @@ class Application { #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ for (auto *obj : this->entities_member##_) { \ - if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \ + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \ return obj; \ } \ return nullptr; \ diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 328de05302..fc6ac503b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,7 +8,7 @@ namespace esphome { static const char *const TAG = "entity_base"; -void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) { +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32 } } this->flags_.has_own_name = false; - // Dynamic name - must calculate key at runtime - this->calc_entity_key_(); + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed key if provided - if (entity_key != 0) { - this->entity_key_ = entity_key; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; } else { - this->calc_entity_key_(); + this->calc_object_id_(); } } // Unpack entity string table indices and flags from entity_fields. @@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Calculate the entity key directly from the raw name (no transformations) -void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); } - -// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility. -// Named entities historically used the hash pre-computed by Python code generation, which -// sanitized per UTF-8 code point; entities without their own name computed the hash at -// runtime per byte. See https://github.com/esphome/backlog/issues/85 -uint32_t EntityBase::calc_old_object_id_hash_() const { - return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name); +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { @@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span buf) c } ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { - // The old key hashed the sanitized object_id, so multiple entity names could collide on - // one key and overwrite each other's stored preferences; the new key hashes the raw name. - // See: https://github.com/esphome/backlog/issues/85 - uint32_t old_key = this->old_preference_key_base_() ^ version; -#ifdef USE_PREFERENCE_KEY_LOOKUP - uint32_t new_key = this->preference_key_base_() ^ version; - auto pref = global_preferences->make_preference(size, new_key); - // All in-tree entity preferences fit the stack buffer, so migration never hits the heap - SmallBufferWithHeapFallback<64> buffer(size); - migrate_preference(pref, buffer.get(), size, old_key, new_key); - return pref; -#else - // Slot-based backends keep the old key: it is only a validity tag on a positional slot, - // so collisions cannot corrupt data there and keeping it preserves stored state. - return global_preferences->make_preference(size, old_key); -#endif + // The key hashes the sanitized object_id, so multiple entity names can collide on one + // key and overwrite each other's stored preferences ("Living Room" and "living_room", + // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name + // fix this, but they change the entity key API clients track, which the Home Assistant + // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + uint32_t key = this->get_preference_hash() ^ version; +#pragma GCC diagnostic pop + return global_preferences->make_preference(size, key); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7f8e5f2630..5f2e173d8d 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,17 +73,8 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the unique key of this Entity: FNV-1 hash of the raw entity name. - // This is the key sent to API clients and used to route entity state. - uint32_t get_entity_key() const { return this->entity_key_; } - - /// Returns the LEGACY object_id hash, unchanged from previous releases, so existing - /// callers keep getting stable values (for example preference keys). This is no longer - /// the key sent to API clients; that is get_entity_key(). - ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or " - "make_entity_preference() for preference storage. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); } + // Get the unique Object ID of this Entity + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) @@ -190,23 +181,39 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } - /// Get this entity's device id, or 0 when devices are not compiled in (main device). - uint32_t get_device_id_or_zero() const { -#ifdef USE_DEVICES - return this->get_device_id(); -#else - return 0; -#endif - } - - /// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id. - /// Intentionally keeps the old algorithm so external callers that store preferences under - /// this key keep stable keys; make_entity_preference() migrates to the new raw-name key, - /// this method never will. + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + * @deprecated Use make_entity_preference() instead, or preferences won't be migrated. + * See https://github.com/esphome/backlog/issues/85 + */ ESPDEPRECATED("Use make_entity_preference() instead, or preferences won't be migrated. " "See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.", - "2026.8.0") - uint32_t get_preference_hash() { return this->old_preference_key_base_(); } + "2026.7.0") + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } /// Create a preference object for storing this entity's state/settings. /// @tparam T The type of data to store (must be trivially copyable) @@ -223,9 +230,9 @@ class EntityBase { // before push_back, so codegen can emit a single combined call per entity. friend class Application; - /// Combined entity setup from codegen: set name, entity key, entity string indices, and flags. + /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. - void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields); + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields); #ifdef USE_DEVICES // Codegen-only setter — only accessible from setup() via friend declaration. @@ -233,24 +240,13 @@ class EntityBase { #endif /// Non-template helper for make_entity_preference() to avoid code bloat. - /// Migrates preferences from the old sanitized-object_id key to the raw-name key - /// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85 + /// When the preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); - void calc_entity_key_(); - - /// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys. - uint32_t calc_old_object_id_hash_() const; - - /// Preference key base for this entity: raw-name entity key XOR device_id. - uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); } - - /// Legacy preference key base: sanitized-object_id hash XOR device_id. - /// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash. - uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); } + void calc_object_id_(); StringRef name_; - uint32_t entity_key_{}; + uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5060e32a2d..54e2551cb4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,86 +25,25 @@ from esphome.core.config import ( from esphome.cpp_generator import MockObj, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case +from esphome.helpers import ( + cpp_string_escape, + fnv1_hash, + fnv1_hash_object_id, + sanitize, + snake_case, +) from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) DOMAIN = "entity_string_pool" -_OBJECT_ID_DOMAIN = "entity_object_ids" - - -@dataclass -class ObjectIdEntity: - """An entity tracked by the sanitized object_id its name resolves to.""" - - name: str - platform: str - config: ConfigType - - -def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]: - """(device_id, platform, sanitized object_id) -> entities resolving to it.""" - return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {}) - - -def validate_no_object_id_conflicts( - reason: str, - conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None, -) -> Callable[[ConfigType], ConfigType]: - """Create a final-validate step that rejects entities with colliding object_ids. - - Entity keys are hashed from the raw name, so names that only differ in characters - lost during sanitizing (for example two UTF-8 names) validate fine in general. - Components that still address entities by the sanitized object_id string must - reject those configs until they are migrated to raw names. - - Args: - reason: One sentence stating what the component builds from the object_id, - e.g. "mqtt builds default topics from the entity object_id" - conflict_filter: Optional predicate receiving the colliding entities and the - component config; return False when the component is not affected - - Returns: - A validator function for use as (or within) FINAL_VALIDATE_SCHEMA - """ - - def validator(config: ConfigType) -> ConfigType: - # Skip in testing_mode, which is used for grouped component testing - if CORE.testing_mode: - return config - conflicts = { - key: entities - for key, entities in _get_object_id_registry().items() - if len(entities) > 1 - and (conflict_filter is None or conflict_filter(entities, config)) - } - if not conflicts: - return config - lines = [f"{reason}, so these entities would conflict:"] - lines.extend( - f" - {platform} entities " - + ", ".join(f"'{e.name}'" for e in entities) - + (f" on device '{device_id}'" if device_id else "") - + f" share the object_id '{object_id}'" - for (device_id, platform, object_id), entities in conflicts.items() - ) - lines.append( - "To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') " - "to distinguish the names" - ) - raise cv.Invalid("\n".join(lines)) - - return validator - - # Private config keys for storing registered string indices _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" _KEY_ENTITY_NAME = "_entity_name" -_KEY_ENTITY_KEY = "_entity_key" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" # Bit layout for entity_fields in configure_entity_(). # Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h @@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] - entity_key = config[_KEY_ENTITY_KEY] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) @@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( - var, entity_name, entity_key, packed + var, entity_name, object_id_hash, packed ) else: - expr = var.configure_entity_(entity_name, entity_key, packed) + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: add(expr) -def get_base_entity_name( +def get_base_entity_object_id( name: str, friendly_name: str | None, device_name: str | None = None ) -> str: - """Return the base name whose hash becomes this entity's key on the device. + """Calculate the base object ID for an entity that will be set via set_object_id(). - Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp): - entity name, then sub-device name, then friendly name, then the device name. + This function calculates what object_id_c_str_ should be set to in C++. - This is a config-time approximation for duplicate checking: when - name_add_mac_suffix is enabled the device appends the MAC suffix at runtime, - which is unknown here and identical for every entity on the device, so - ignoring it cannot change whether two entities collide with each other. + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() """ - return name or device_name or friendly_name or CORE.name + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif device_name: + # Entity has empty name and is on a sub-device + # C++ EntityBase::set_name() uses device->get_name() when device is set + base_str = device_name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) def setup_entity(var_or_platform, config=None, platform=None): @@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device_(device)) - # Pre-compute entity name and entity key for configure_entity_() + # Pre-compute entity name and object_id hash for configure_entity_() # which is emitted later by finalize_entity_strings(). - # For named entities: pre-compute the key from the raw entity name - # For empty-name entities: pass 0, C++ calculates the key at runtime from - # device name, friendly_name, or app name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - entity_key = fnv1_hash_name(entity_name) if entity_name else 0 + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 config[_KEY_ENTITY_NAME] = entity_name - config[_KEY_ENTITY_KEY] = entity_key + config[_KEY_OBJECT_ID_HASH] = object_id_hash # Store flags for packing into configure_entity_() config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT]) if CONF_INTERNAL in config: @@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Use the device ID string directly for uniqueness device_id = device_id_obj.id - # Hash the same raw name the device hashes into the entity key at runtime. - # This handles empty names correctly by using device/friendly names. - base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name) - name_hash = fnv1_hash_name(base_name) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) - # Check for duplicates: two entities on the same device and platform must not - # share an entity key, since the key is what routes state to API clients + # Check for duplicates by the FNV-1 hash of the object_id, which is the entity + # key that routes state to API clients. This rejects names that sanitize to the + # same object_id, and also two different object_ids whose 32-bit hashes collide. + name_hash = fnv1_hash(name_key) unique_key = (device_id, platform, name_hash) if unique_key in CORE.unique_ids: # Get the existing entity metadata @@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" - # Different names can only clash here through a genuine hash collision + # Distinguish names that sanitize to the same object_id from a genuine + # 32-bit hash collision between two different object_ids collision_msg = "" if entity_name != existing_name: - collision_msg = ( - f"\n The names '{entity_name}' and '{existing_name}' produce the" - f"\n same entity key hash ({name_hash:#010x})." - "\n To fix: Rename one of the entities" + existing_object_id = get_base_entity_object_id( + existing_name, CORE.friendly_name, existing_device or None ) + if existing_object_id == name_key: + collision_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + else: + collision_msg = ( + f"\n The object_ids '{name_key}' and '{existing_object_id}'" + f"\n produce the same entity key hash ({name_hash:#010x})." + "\n To fix: Rename one of the entities" + ) # Skip duplicate entity name validation when testing_mode is enabled # This flag is used for grouped component testing @@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy f"{collision_msg}" ) - # Components that still address entities by the sanitized object_id reject - # colliding names in final validation via validate_no_object_id_conflicts(), - # so track every entity by the object_id its name resolves to. Scoped per - # device and platform to match the strictness configs had before entity keys - # moved to raw names: same-named entities on different sub-devices were - # already accepted then, internal entities were already skipped (above), and - # overlaps between platforms that share an MQTT component type (sensor and - # text_sensor both publish under "sensor") were already possible. - object_id = sanitize(snake_case(base_name)) - _get_object_id_registry().setdefault( - (device_id, platform, object_id), [] - ).append(ObjectIdEntity(base_name, platform, config)) - # Store metadata about this entity entity_metadata: EntityMetadata = { "name": entity_name, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d883ce146e..994fa2c26a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; /// FNV-1 32-bit prime constexpr uint32_t FNV1_PRIME = 16777619UL; -/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *), -/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80. -/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8 -/// encoded bytes of the name. Used to compute entity keys from raw names. -inline uint32_t fnv1_hash_bytes(const char *str, size_t len) { - uint32_t hash = FNV1_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - hash *= FNV1_PRIME; - hash ^= static_cast(str[i]); - } - return hash; -} - /// Extend a FNV-1 hash with an integer (hashes each byte). template constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) { using UnsignedT = std::make_unsigned_t; @@ -1026,20 +1013,12 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s // str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. -/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing -/// devices already have stored; see https://github.com/esphome/backlog/issues/85. -/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character -/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py, -/// which produced the hash for named entities. The per-byte form (default) matches the old -/// runtime hash for entities without their own name. Do not change either behavior. -/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a -/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong; -/// such names skip migration once and fall back to their defaults. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) { +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { - if (per_code_point && (static_cast(str[i]) & 0xC0) == 0x80) - continue; // UTF-8 continuation byte, already counted via its lead byte hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); diff --git a/esphome/core/preference_backend.h b/esphome/core/preference_backend.h index 0622376fca..5df0804bdd 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -24,9 +24,10 @@ #endif // Key-lookup preference backends find stored data by key; their platforms add the -// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key -// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for -// every make_preference() call and use the key only as a validity tag on that slot; +// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads +// of stored data by key (the primitive preference key migrations need). Slot-based +// backends (ESP8266, RP2040) instead allocate a storage slot for every +// make_preference() call and use the key only as a validity tag on that slot; // migration is not possible there, and key collisions cannot corrupt data. namespace esphome { @@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool }; // Key-lookup platforms additionally provide load_from_key(), a one-shot read -// of a stored preference by key that migrate_preference() relies on; see the -// key-lookup note at the top of this file. Not part of PreferencesContract, -// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP -// is set. +// of a stored preference by key; see the key-lookup note at the top of this +// file. Not part of PreferencesContract, so it is asserted in preferences.h +// only where USE_PREFERENCE_KEY_LOOKUP is set. template concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) { { prefs.load_from_key(type, data, len) } -> std::same_as; diff --git a/esphome/core/preferences.cpp b/esphome/core/preferences.cpp deleted file mode 100644 index 8508647255..0000000000 --- a/esphome/core/preferences.cpp +++ /dev/null @@ -1,25 +0,0 @@ -#include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include - -namespace esphome { - -#ifdef USE_PREFERENCE_KEY_LOOKUP -static const char *const TAG = "preferences"; - -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key) { - if (new_pref.load(scratch, size)) - return true; // Current data present - never overwrite newer data with the old copy - // One-shot read by key: no backend is allocated for the old key, so boots with - // nothing to migrate (for example fresh installs) cost no heap - if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size)) - return false; // No data stored under the old key, nothing to migrate - if (!new_pref.save(scratch, size)) { - ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key); - } - return true; -} -#endif // USE_PREFERENCE_KEY_LOOKUP - -} // namespace esphome diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index cfeddebda7..ed23dfae56 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -56,17 +56,5 @@ namespace esphome { static_assert(PreferencesKeyLookupContract, "This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide " "load_from_key() (esphome/core/preference_backend.h)"); - -/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys -/// differ and new_pref has no data yet. scratch must hold at least size bytes. -/// Returns true when scratch holds the entity's current data (loaded or just migrated). -/// The old entry is intentionally left in place so a firmware downgrade still finds its data. -/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get -/// valid data for this boot, callers that reload from the preference fall back to their -/// defaults, and the migration simply runs again on the next boot. -/// Only available on key-lookup preference backends; slot-based backends keep their old -/// keys instead. See: https://github.com/esphome/backlog/issues/85 -bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key, - uint32_t new_key); } // namespace esphome #endif // USE_PREFERENCE_KEY_LOOKUP diff --git a/esphome/helpers.py b/esphome/helpers.py index 2731109164..9b2a461ccd 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int: def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h - with per_code_point set. This is the OLD entity hash; it computes preference - keys that existing devices already have stored (see - https://github.com/esphome/backlog/issues/85) and is also still used for live - keys derived from config IDs (see the motion component's calibration key). - Note: lower() here is Unicode aware while the C++ reconstruction is not; see - the known limitation note on the C++ function. + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + If you modify this function, update the C++ version and tests in both places. """ return fnv1_hash(sanitize(snake_case(name))) @@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int: def fnv1_hash_name(name: str) -> int: """Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations). - IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h, - which hashes the name bytes as stored on the device. - Used for pre-computing entity keys at code generation time. + 2026.8 beta firmware stored preferences under keys derived from this hash; + a future key migration must reconstruct those keys to recover that data + (see https://github.com/esphome/backlog/issues/85). """ return _fnv1_hash(name.encode("utf-8")) diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py index 95f6a0321e..7596983ee2 100644 --- a/tests/integration/entity_utils.py +++ b/tests/integration/entity_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case if TYPE_CHECKING: from aioesphomeapi import DeviceInfo, EntityInfo @@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: return device_info.name.endswith(f"-{mac_suffix}") -def _resolve_entity_name( +def _get_name_for_object_id( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> str: - """Resolve the effective name for an entity. + """Get the name used for object_id computation. This is the algorithm that aioesphomeapi will use to determine which - name to use for computing object_id client-side from API data; the same - name is what the device hashes into the entity key. + name to use for computing object_id client-side from API data. Args: entity: The entity to get name for @@ -73,27 +72,27 @@ def compute_entity_object_id( Returns: The computed object_id string """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return compute_object_id(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) -def compute_entity_key( +def compute_entity_hash( entity: EntityInfo, device_info: DeviceInfo, device_id_to_name: dict[int, str], ) -> int: - """Compute expected entity key for an entity. + """Compute expected object_id hash for an entity. Args: - entity: The entity to compute the key for + entity: The entity to compute hash for device_info: Device info from the API device_id_to_name: Mapping of device_id to device name for sub-devices Returns: - The computed FNV-1 hash of the raw name + The computed FNV-1 hash """ - name = _resolve_entity_name(entity, device_info, device_id_to_name) - return fnv1_hash_name(name) + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) def verify_entity_object_id( @@ -119,7 +118,7 @@ def verify_entity_object_id( f"expected '{expected_object_id}', got '{entity.object_id}'" ) - expected_hash = compute_entity_key(entity, device_info, device_id_to_name) + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) assert entity.key == expected_hash, ( f"hash mismatch for entity '{entity.name}': " f"expected {expected_hash:#x}, got {entity.key:#x}" diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index d4511bb8c6..2097b2fbf9 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -71,38 +71,6 @@ esphome: ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); } - // Raw name hash: matches Python fnv1_hash_name("My Sensor Name") - uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14); - if (hash_raw == 0x8cec6fb0) { - ESP_LOGI("FNV1_OID", "raw PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw); - } - - // Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température") - uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12); - if (hash_raw_utf8 == 0x531a74aa) { - ESP_LOGI("FNV1_OID", "raw_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8); - } - - // Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température") - uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true); - if (hash_old_utf8 == 0x965698f3) { - ESP_LOGI("FNV1_OID", "old_utf8 PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8); - } - - // Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度") - uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true); - if (hash_old_cjk == 0x3276cb9f) { - ESP_LOGI("FNV1_OID", "old_cjk PASSED"); - } else { - ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk); - } - host: api: logger: diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 582add90a8..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -156,17 +156,10 @@ button: ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); - // Log preference key bases for entities that actually store preferences. - // This is the key base make_entity_preference() uses: entity key XOR device id. - ESP_LOGI("test", "Device A Switch Pref Hash: %u", - id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Switch Pref Hash: %u", - id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Switch Pref Hash: %u", - id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero()); - ESP_LOGI("test", "Device A Number Pref Hash: %u", - id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero()); - ESP_LOGI("test", "Device B Number Pref Hash: %u", - id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero()); - ESP_LOGI("test", "Main Number Pref Hash: %u", - id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/fixtures/preference_key_migration.yaml b/tests/integration/fixtures/preference_key_migration.yaml deleted file mode 100644 index a9b01fc2d2..0000000000 --- a/tests/integration/fixtures/preference_key_migration.yaml +++ /dev/null @@ -1,35 +0,0 @@ -esphome: - name: host-pref-key-migration - -host: -api: -logger: - -switch: - - platform: template - id: test_switch_restore - name: Test Switch - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - -number: - - platform: template - id: test_number_restore - name: Test Number - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0 - max_value: 100 - step: 0.5 - -text: - - platform: template - id: test_text_restore - name: Test Text - mode: text - optimistic: true - restore_value: true - initial_value: fallback - min_length: 0 - max_length: 20 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..f835bee3bc 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None: host_prefs_path(device_name).unlink(missing_ok=True) -def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: - """Write preference entries, replacing the file's contents. - - Returns the path that was written. - """ - payload = b"" - for key, data in entries.items(): - if len(data) > 255: - raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: """Write a single preference entry, replacing the file's contents. Returns the path that was written. """ - return write_host_prefs(device_name, {key: data}) + if len(data) > 255: + raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") + path = host_prefs_path(device_name) + path.parent.mkdir(parents=True, exist_ok=True) + payload = struct.pack(" None: diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 8dafb37c64..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -2,8 +2,8 @@ This test verifies a three-way match between: 1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) -2. C++ entity key generation (fnv1_hash of the raw name in helpers.h) -3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) The API response contains C++ computed values, so verifying API == Python implicitly verifies C++ == Python == API for both object_id and hash. @@ -25,7 +25,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -123,7 +123,7 @@ async def test_object_id_api_verification( ) # Verify hash can be computed from the name - hash_from_name = fnv1_hash_name(entity_name) + hash_from_name = fnv1_hash_object_id(entity_name) assert hash_from_name == entity.key, ( f"Entity '{entity_name}': hash mismatch. " f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" @@ -164,7 +164,7 @@ async def test_object_id_api_verification( ) # Verify hash matches - expected_hash = fnv1_hash_name(expected_name) + expected_hash = fnv1_hash_object_id(expected_name) assert entity.key == expected_hash, ( f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " f"API key: {entity.key:#x}, expected: {expected_hash:#x}" diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b58593f2ef..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import ( compute_object_id, @@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix( ) # Hash should match friendly_name - expected_hash = fnv1_hash_name("My Friendly Device") + expected_hash = fnv1_hash_object_id("My Friendly Device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 45b5f730a6..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_name +from esphome.helpers import fnv1_hash_object_id from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction @@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( OLD behavior: - is_object_id_dynamic_() returned false (mac suffix not enabled) - Used object_id_c_str_ which was pre-computed in Python - - Python used get_base_entity_name() with fallback to CORE.name + - Python used get_base_entity_object_id() with fallback to CORE.name Result: object_id = sanitize(snake_case(device_name)) """ @@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( ) # Hash should match device name - expected_hash = fnv1_hash_name("test-device") + expected_hash = fnv1_hash_object_id("test-device") assert entity.key == expected_hash, ( f"Expected hash {expected_hash:#x}, got {entity.key:#x}" ) diff --git a/tests/integration/test_preference_key_migration.py b/tests/integration/test_preference_key_migration.py deleted file mode 100644 index e7f699bb12..0000000000 --- a/tests/integration/test_preference_key_migration.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration test for entity preference key migration. - -Entity keys are now the FNV-1 hash of the raw name instead of the sanitized -object_id (https://github.com/esphome/backlog/issues/85). On key-lookup -preference backends, make_entity_preference() must move data stored under the -old key to the new key, so devices keep their restored state after upgrading. - -This test seeds the host preferences file the way a pre-migration firmware -would have written it and verifies: -1. Data stored under the OLD key is restored (migration happened, no data loss) -2. Data already stored under the NEW key is never overwritten by old data -""" - -from __future__ import annotations - -import socket -import struct - -from aioesphomeapi import ( - NumberInfo, - NumberState, - SwitchInfo, - SwitchState, - TextInfo, - TextState, -) -import pytest - -from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id - -from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client -from .host_prefs import clear_host_prefs, write_host_prefs -from .state_utils import InitialStateHelper, require_entity -from .types import CompileFunction, ConfigWriter - -DEVICE_NAME = "host-pref-key-migration" - -# The pre-migration preference key was the sanitized object_id hash; the new -# key is the raw-name hash. All entities are on the main device (device_id 0) -# and their preferences use no version salt, so the key is just the hash. -SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch") -SWITCH_NEW_KEY = fnv1_hash_name("Test Switch") -NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number") -NUMBER_NEW_KEY = fnv1_hash_name("Test Number") - -# template_text salts its key with the length limits and pattern hash; this must -# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20, -# no pattern configured) -TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6) -TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF -TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF - -# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes -TEXT_MAX_LENGTH = 20 - - -def text_pref_payload(value: str) -> bytes: - """Build the length-prefixed buffer TextSaver stores for a value.""" - data = value.encode("utf-8") - assert len(data) <= TEXT_MAX_LENGTH - return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data)) - - -@pytest.mark.asyncio -async def test_preference_key_migration( - yaml_config: str, - write_yaml_config: ConfigWriter, - compile_esphome: CompileFunction, - reserved_tcp_port: tuple[int, socket.socket], -) -> None: - """Test that preferences stored under the old key survive the upgrade.""" - port, port_socket = reserved_tcp_port - - assert SWITCH_OLD_KEY != SWITCH_NEW_KEY - assert NUMBER_OLD_KEY != NUMBER_NEW_KEY - assert TEXT_OLD_KEY != TEXT_NEW_KEY - - # Write and compile once - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - - # Release the reserved port so the binary can bind to it - port_socket.close() - - async def boot_and_get_initial_states() -> tuple[ - SwitchState, NumberState, TextState - ]: - """Boot the binary and return the restored entity states.""" - async with ( - run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), - wait_and_connect_api_client(port=port) as client, - ): - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - - entities, _ = await client.list_entities_services() - switch_entity = require_entity( - entities, "test_switch", SwitchInfo, "Test Switch" - ) - number_entity = require_entity( - entities, "test_number", NumberInfo, "Test Number" - ) - text_entity = require_entity(entities, "test_text", TextInfo, "Test Text") - - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(lambda s: None) - ) - await initial_state_helper.wait_for_initial_states() - - switch_state = initial_state_helper.initial_states[switch_entity.key] - number_state = initial_state_helper.initial_states[number_entity.key] - text_state = initial_state_helper.initial_states[text_entity.key] - assert isinstance(switch_state, SwitchState) - assert isinstance(number_state, NumberState) - assert isinstance(text_state, TextState) - return switch_state, number_state, text_state - - try: - # --- Run 1: only OLD keys present, as written by pre-migration firmware. - # The restored states prove the data was migrated to the new keys. - write_host_prefs( - DEVICE_NAME, - { - SWITCH_OLD_KEY: b"\x01", # bool: switch was ON - NUMBER_OLD_KEY: struct.pack(" None: - """Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe. - - Drift silently reintroduces shared subscribe topics, so this derives the set - from the C++ components that actually call subscribe(); that also catches - platforms like text that subscribe a command topic without exposing a - command_topic key in their schema. - """ - expected: set[str] = set() - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"): - if path.stem in _NON_ENTITY_MQTT_SOURCES: - continue - if "this->subscribe" not in path.read_text(encoding="utf-8"): - continue - stem = path.stem.removeprefix("mqtt_") - expected.add("datetime" if stem in _DATETIME_STEMS else stem) - assert expected == _COMMAND_TOPIC_PLATFORMS - - -def test_sub_topic_platforms_in_sync() -> None: - """Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics. - - Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra - topics such as position/command from the object_id. - """ - expected = { - path.stem.removeprefix("mqtt_") - for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h") - if path.stem != "mqtt_component" - and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8") - } - assert expected == _SUB_TOPIC_PLATFORMS - - -def test_conflict_filter_exempts_custom_topics() -> None: - """Test that custom state topics with discovery off avoid the conflict.""" - validator = entity_duplicate_validator("sensor") - # Both entities have custom state topics and discovery disabled per entity, - # so no object_id-derived MQTT topic is used - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - # Without the filter the same conflicts are fatal - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - validate_no_object_id_conflicts(REASON)({}) - - -def test_conflict_on_default_command_topic() -> None: - """Test that commandable platforms conflict through their default command topic. - - Custom state topics with discovery off are not enough for platforms that also - subscribe to an object_id-derived command topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - # Both switches share the default command topic: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator(mqtt_config) - - # With custom command topics as well, nothing derives from the object_id - CORE.reset() - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - assert component_validator(mqtt_config) is mqtt_config - - -def test_conflict_on_sub_topic_platforms() -> None: - """Test that platforms with extra object_id sub-topics always conflict. - - Covers derive topics like position/command from the object_id through their - own config keys, so custom state and command topics cannot exempt them. - """ - validator = entity_duplicate_validator("cover") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_COMMAND_TOPIC: "custom/cmd/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_STATE_TOPIC: "custom/topic/b", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}) - - -def test_no_conflict_on_disjoint_default_topics() -> None: - """Test that entities whose default topics are disjoint do not conflict. - - One entity uses only the default command topic and the other only the default - state topic, so they never share a topic. - """ - validator = entity_duplicate_validator("switch") - validator( - { - CONF_NAME: "Датчик открытия", - CONF_STATE_TOPIC: "custom/topic/a", - CONF_DISCOVERY: False, - } - ) - validator( - { - CONF_NAME: "Датчик закрытия", - CONF_COMMAND_TOPIC: "custom/cmd/b", - CONF_DISCOVERY: False, - } - ) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"} - assert component_validator(config) is config - - -def test_no_conflict_on_empty_topic_prefix() -> None: - """Test that an empty topic_prefix disables the default topic conflict. - - With topic_prefix set to null no default topics exist at runtime, so entities - without custom state topics cannot conflict; only discovery still matters. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - REASON, conflict_filter=_topics_conflict - ) - # No default topics and no discovery: valid - config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""} - assert component_validator(config) is config - - # Discovery still uses object_id-derived config topics: rejected - with pytest.raises(Invalid, match=r"mqtt builds default topics"): - component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""}) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 64400c4fd4..53035ad713 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,4 +1,4 @@ -"""Tests for entity helpers: name selection, entity key hashing, duplicate checks.""" +"""Test get_base_entity_object_id function matches C++ behavior.""" from collections.abc import Callable, Generator from pathlib import Path @@ -25,17 +25,16 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, finalize_entity_strings, - get_base_entity_name, + get_base_entity_object_id, register_device_class, register_icon, register_unit_of_measurement, setup_device_class, setup_entity, setup_unit_of_measurement, - validate_no_object_id_conflicts, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_name, sanitize, snake_case +from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]: CORE.friendly_name = original_friendly_name -def test_get_base_entity_name_priority_order() -> None: +def test_with_entity_name() -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor" + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + # Even with device name, entity name takes precedence + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor" + + # Mixed case + assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor" + + +def test_empty_name_with_device_name() -> None: + """Test when entity has empty name and is on a sub-device - should use device name.""" + # C++ behavior: when has_own_name is false and device is set, uses device->get_name() + assert ( + get_base_entity_object_id("", "Friendly Device", "Sub Device 1") + == "sub_device_1" + ) + assert ( + get_base_entity_object_id("", "Kitchen Controller", "controller_1") + == "controller_1" + ) + assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123" + + +def test_empty_name_with_friendly_name() -> None: + """Test when entity has empty name and no device - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + +def test_empty_name_no_friendly_name() -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + +def test_edge_cases() -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ], +) +def test_matches_cpp_helpers(name: str, expected: str) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + +def test_empty_name_fallback() -> None: + """Test empty name handling which falls back to friendly_name or CORE.name.""" + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + + +def test_name_add_mac_suffix_behavior() -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name" + + +def test_priority_order() -> None: """Test the priority order: entity name > device name > friendly name > CORE.name.""" CORE.name = "core-device" - # 1. Entity name has highest priority and is used as-is, no transformations + # 1. Entity name has highest priority assert ( - get_base_entity_name("Entity Name", "Friendly Name", "Device Name") - == "Entity Name" + get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name") + == "entity_name" ) - assert get_base_entity_name("Température", None) == "Température" # 2. Device name is next priority (when entity name is empty) - assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name" + assert ( + get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name" + ) # 3. Friendly name is next (when entity and device names are empty) - assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name" + assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name" - # 4. CORE.name is last resort; an empty friendly name falls through to it - assert get_base_entity_name("", None, None) == "core-device" - assert get_base_entity_name("", "") == "core-device" + # 4. CORE.name is last resort + assert get_base_entity_object_id("", None, None) == "core-device" + + +@pytest.mark.parametrize( + ("name", "friendly_name", "device_name", "expected"), + [ + # name, friendly_name, device_name, expected + ("Living Room Light", None, None, "living_room_light"), + ("", "Kitchen Controller", None, "kitchen_controller"), + ( + "", + "ESP32 Device", + "controller_1", + "controller_1", + ), # Device name takes precedence + ("GPIO2 Button", None, None, "gpio2_button"), + ("WiFi Signal", "My Device", None, "wifi_signal"), + ("", None, "esp32_node", "esp32_node"), + ("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"), + ], +) +def test_real_world_examples( + name: str, friendly_name: str | None, device_name: str | None, expected: str +) -> None: + """Test real-world entity naming scenarios.""" + result = get_base_entity_object_id(name, friendly_name, device_name) + assert result == expected + + +def test_issue_6953_scenarios() -> None: + """Test specific scenarios from issue #6953.""" + # Scenario 1: Multiple empty names on main device with name_add_mac_suffix + # The Python code calculates the base, C++ might append MAC suffix dynamically + CORE.name = "device-name" + CORE.friendly_name = "Friendly Device" + + # All empty names should resolve to same base + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device" + + # Scenario 2: Empty names on sub-devices + assert ( + get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1" + ) + assert ( + get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2" + ) + + # Scenario 3: xyz duplicates + assert get_base_entity_object_id("xyz", None) == "xyz" + assert get_base_entity_object_id("xyz", "Device") == "xyz" # Tests for setup_entity function @@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Check metadata was stored - metadata = CORE.unique_ids[temperature_key] + metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))] assert metadata["name"] == "Temperature" assert metadata["platform"] == "sensor" @@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None: config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - humidity_key = ("", "sensor", fnv1_hash_name("Humidity")) - assert humidity_key in CORE.unique_ids - metadata2 = CORE.unique_ids[humidity_key] + assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))] assert metadata2["name"] == "Humidity" # Duplicate entity should fail @@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None: validator(config3) +def test_entity_duplicate_validator_hash_collision() -> None: + """Test that two different object_ids with the same FNV-1 hash are rejected.""" + # Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4 + name_a = "Sensor aooxzi" + name_b = "Sensor baraia" + object_id_a = sanitize(snake_case(name_a)) + object_id_b = sanitize(snake_case(name_b)) + assert object_id_a != object_id_b + assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b) + + validator = entity_duplicate_validator("sensor") + + config1 = {CONF_NAME: name_a} + validated1 = validator(config1) + assert validated1 == config1 + + config2 = {CONF_NAME: name_b} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate sensor entity with name 'Sensor baraia' found.*" + r"produce the same entity key hash \(0xe95747e4\)", + re.DOTALL, + ), + ): + validator(config2) + + def test_entity_duplicate_validator_with_devices() -> None: """Test entity_duplicate_validator with devices.""" # Create validator for sensor platform @@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None: device2 = ID("device2", type="Device") # Same name on different devices should pass - name_hash = fnv1_hash_name("Temperature") config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", name_hash) in CORE.unique_ids - metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)] + assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))] assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 - assert ("device2", "sensor", name_hash) in CORE.unique_ids - metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)] + assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))] assert metadata2["device_id"] == "device2" # Duplicate on same device should fail @@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation( assert result is not None -def test_object_id_conflict_mqtt_yaml_validation( - yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] -) -> None: - """Test that names sanitizing to the same object_id fail when mqtt is configured.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR - ) - assert result is None - - captured = capsys.readouterr() - assert ( - "mqtt builds default topics and discovery topics from the entity object_id" - in captured.out - ) - - -def test_object_id_conflict_without_mqtt_yaml_validation( - yaml_file: Callable[[str], str], -) -> None: - """Test that names sanitizing to the same object_id pass without mqtt/prometheus.""" - result = load_config_from_fixture( - yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR - ) - # This should succeed - assert result is not None - - def test_entity_duplicate_validator_error_message() -> None: """Test that duplicate entity error messages include helpful metadata.""" # Create validator for sensor platform @@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated1 = validator(config1) assert validated1 == config1 # New format includes device_id (empty string for main device) - temperature_key = ("", "sensor", fnv1_hash_name("Temperature")) - assert temperature_key in CORE.unique_ids + assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids # Internal entity with same name should pass (not added to unique_ids) config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} @@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: assert validated2 == config2 # Internal entity should not be added to unique_ids # Count how many times the key appears (should still be 1) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Another internal entity with same name should also pass @@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - count = sum(1 for k in CORE.unique_ids if k == temperature_key) + count = sum( + 1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature")) + ) assert count == 1 # Non-internal entity with same name should fail @@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None: def test_entity_duplicate_validator_non_ascii_names() -> None: - """Test that distinct non-ASCII names no longer collide. - - These names used to be rejected because both sanitize to only underscores; - the entity key now hashes the raw name so they stay distinct. - """ + """Test that non-ASCII names show helpful error messages.""" # Create validator for binary_sensor platform validator = entity_duplicate_validator("binary_sensor") - # Both Russian sensors should pass even though they sanitize identically + # First Russian sensor should pass config1 = {CONF_NAME: "Датчик открытия основного крана"} validated1 = validator(config1) assert validated1 == config1 + # Second Russian sensor with different text but same ASCII conversion should fail config2 = {CONF_NAME: "Датчик закрытия основного крана"} - validated2 = validator(config2) - assert validated2 == config2 - - # An exact duplicate still fails - config3 = {CONF_NAME: "Датчик открытия основного крана"} - with pytest.raises( - Invalid, - match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found", - ): - validator(config3) - - -def test_entity_duplicate_validator_hash_collision() -> None: - """Test that two different names with the same FNV-1 hash are rejected.""" - # Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b - name_a = "Sensor m2CZ" - name_b = "Sensor qCaa" - assert name_a != name_b - assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b) - - validator = entity_duplicate_validator("sensor") - - config1 = {CONF_NAME: name_a} - validated1 = validator(config1) - assert validated1 == config1 - - config2 = {CONF_NAME: name_b} with pytest.raises( Invalid, match=re.compile( - rf"Duplicate sensor entity with name '{name_b}' found.*" - rf"The names '{name_b}' and '{name_a}' produce the.*" - r"same entity key hash \(0x0ee5ff7b\).*" - r"To fix: Rename one of the entities", + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", re.DOTALL, ), ): validator(config2) -def test_object_id_conflicts_rejected_by_component_validator() -> None: - """Test that object_id conflicts pass entity validation but fail for mqtt/prometheus.""" - validator = entity_duplicate_validator("sensor") - - # Both names validate fine in general (distinct raw names, distinct keys) - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - # A component that addresses entities by object_id must reject the config - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - with pytest.raises( - Invalid, - match=re.compile( - r"mqtt builds default topics from the entity object_id.*" - r"sensor entities 'Датчик открытия', 'Датчик закрытия' " - r"share the object_id '_______________'.*" - r"To fix: Add unique ASCII characters", - re.DOTALL, - ), - ): - component_validator({}) - - -def test_object_id_conflicts_skipped_in_testing_mode() -> None: - """Test that testing_mode skips the conflict check, as used for grouped testing.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Датчик открытия"}) - validator({CONF_NAME: "Датчик закрытия"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - CORE.testing_mode = True - try: - config: dict = {} - assert component_validator(config) is config - finally: - CORE.testing_mode = False - - -def test_object_id_conflicts_none_recorded() -> None: - """Test that distinct object_ids produce no conflicts.""" - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature"}) - validator({CONF_NAME: "Humidity"}) - - component_validator = validate_no_object_id_conflicts( - "mqtt builds default topics from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - -def test_object_id_conflicts_device_scoped() -> None: - """Test that the object_id conflict check is scoped per device. - - Same-named entities on different sub-devices were accepted before entity keys - moved to raw names, so the check keeps that scope; conflicts within one device - are still reported with the device named in the message. - """ - validator = entity_duplicate_validator("sensor") - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")}) - validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")}) - - component_validator = validate_no_object_id_conflicts( - "prometheus builds metric labels from the entity object_id" - ) - config: dict = {} - assert component_validator(config) is config - - # Two names sanitizing identically on the same sub-device still conflict - validator( - {CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - validator( - {CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")} - ) - with pytest.raises( - Invalid, - match=re.compile( - r"prometheus builds metric labels.*on device 'device1'", re.DOTALL - ), - ): - component_validator({}) - - def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: """Test that identical names don't show the enhanced message.""" # Create validator for sensor platform @@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -822,7 +884,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -853,7 +915,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime assert config.get("_entity_name") == "" - assert config.get("_entity_key") == 0 + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml deleted file mode 100644 index 4a6f56f473..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_mqtt.yaml +++ /dev/null @@ -1,22 +0,0 @@ -esphome: - name: test-object-id-conflict - -esp32: - board: esp32dev - -wifi: - ssid: MySSID - password: password1 - -mqtt: - broker: test.mosquitto.org - -sensor: - # Distinct raw names are fine in general, but both sanitize to the same - # object_id, which MQTT still uses to build default topics - should fail - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml b/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml deleted file mode 100644 index c0fbd5cbba..0000000000 --- a/tests/unit_tests/fixtures/core/entity_helpers/object_id_conflict_no_mqtt.yaml +++ /dev/null @@ -1,15 +0,0 @@ -esphome: - name: test-object-id-ok - -esp32: - board: esp32dev - -sensor: - # Distinct raw names that sanitize to the same object_id are allowed when no - # component addresses entities by object_id (no mqtt or prometheus configured) - - platform: template - name: "Датчик открытия" - lambda: return 21.0; - - platform: template - name: "Датчик закрытия" - lambda: return 22.0; diff --git a/tests/unit_tests/test_preference_hash_stability.py b/tests/unit_tests/test_preference_hash_stability.py index d3e5fac36a..d8506afae7 100644 --- a/tests/unit_tests/test_preference_hash_stability.py +++ b/tests/unit_tests/test_preference_hash_stability.py @@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on firmware upgrades, or break entity state routing to API clients. Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85): -1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1). - Existing devices have preferences stored under keys derived from it; slot-based - backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it. -2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes). - Sent to API clients and used as the preference key base on key-lookup backends. +1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1). + The entity key sent to API clients and the base of every stored preference key. +2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta + firmware stored preferences under keys derived from it; a future key migration + must reconstruct those keys to recover that data. DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm, the change breaks backward compatibility and will cause data loss. @@ -124,8 +124,9 @@ def test_entity_object_id_hash_stability( """Verify fnv1_hash_object_id produces stable hashes for entity names. CRITICAL: These expected values MUST NOT CHANGE. Existing devices have - preferences stored under keys derived from this legacy hash; changing it - breaks the old-to-new key migration and loses stored preferences. + preferences stored under keys derived from this hash, and it is the entity + key sent to API clients; changing it loses stored preferences and breaks + entity state routing. """ actual = fnv1_hash_object_id(entity_name) assert actual == expected_object_id_hash, ( @@ -144,9 +145,8 @@ def compute_legacy_preference_key( ) -> int: """Compute the legacy preference key: (object_id_hash ^ device_id) ^ version. - This is the key existing devices have data stored under. Slot-based backends - (ESP8266, RP2040) still use it directly; key-lookup backends compute it as the - migration source in EntityBase::make_entity_preference_() (entity_base.cpp). + This is the key EntityBase::make_entity_preference_() (entity_base.cpp) + stores every entity preference under. """ object_id_hash = fnv1_hash_object_id(entity_name) preference_hash = object_id_hash ^ device_id @@ -179,8 +179,8 @@ def test_legacy_preference_key_computation( ) -> None: """Verify legacy preference key computation matches expected values. - This test ensures the formula doesn't change, which would break both slot-based - preference storage and the migration source keys on key-lookup backends. + This test ensures the formula doesn't change, which would lose stored + preferences on every platform. """ actual_key = compute_legacy_preference_key(entity_name, version, device_id) @@ -215,12 +215,12 @@ def test_legacy_preference_key_computation( ], ) def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None: - """Verify fnv1_hash_name produces stable entity keys. + """Verify fnv1_hash_name produces stable raw-name hashes. - CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to - API clients and is the new preference key base; changing the algorithm - would break state routing and lose stored preferences. - Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h. + CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored + preferences under keys derived from this hash; a future key migration must + reconstruct those keys, and changing the algorithm would strand that data. + Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores. """ actual = fnv1_hash_name(entity_name) assert actual == expected_key, ( From b794b7b1d19d7491df12d417bca5ad19187ac767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 00:22:22 -0500 Subject: [PATCH 172/597] [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) --- esphome/config_validation.py | 4 ++++ esphome/util.py | 14 ++++++++++++++ tests/unit_tests/test_config_validation.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0eebf12e66..f455c7b8bf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -99,6 +99,10 @@ from esphome.schema_extractors import ( schema_extractor_registry, schema_extractor_typed, ) + +# Deprecated re-export for external components; remove before 2027.2.0 +# pylint: disable-next=unused-import +from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base diff --git a/esphome/util.py b/esphome/util.py index 2fc34f3a69..b8ffa048ca 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -390,6 +390,20 @@ def is_dev_esphome_version(): return "dev" in const.__version__ +# Remove before 2027.2.0 +def parse_esphome_version() -> tuple[int, int, int]: + """Deprecated: use esphome.config_validation.require_esphome_version instead.""" + from esphome.core import Version + + _LOGGER.warning( + "parse_esphome_version() is deprecated. Use " + "cv.require_esphome_version to gate on a minimum version. " + "Removed in 2027.2.0" + ) + version = Version.parse(const.__version__) + return version.major, version.minor, version.patch + + # Custom OrderedDict with nicer repr method for debugging class OrderedDict(collections.OrderedDict): def __repr__(self): diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7627ef9273..971c4e462d 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2967,6 +2967,23 @@ def test_require_esphome_version_older_prerelease_fails() -> None: cv.require_esphome_version(2026, 8, 0)("test") +def test_parse_esphome_version_deprecated_shim( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removed helper still works for external components and warns.""" + from esphome import const, util + + with ( + patch.object(const, "__version__", "2026.9.0-dev"), + caplog.at_level(logging.WARNING), + ): + assert cv.parse_esphome_version() == (2026, 9, 0) + assert cv.parse_esphome_version() < (9999, 0, 0) + assert "parse_esphome_version() is deprecated" in caplog.text + # Both historical import paths resolve to the same function + assert cv.parse_esphome_version is util.parse_esphome_version + + # --------------------------------------------------------------------------- # suppress_invalid / validate_source_shorthand / rename_key # --------------------------------------------------------------------------- From c8de63276479cc80db40c03079b90b7c97a11f4f Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Fri, 14 Aug 2026 07:23:34 +0200 Subject: [PATCH 173/597] [core] fix PYTHONPATH leak (#18360) --- esphome/espidf/toolchain.py | 2 ++ esphome/framework_helpers.py | 2 ++ tests/unit_tests/test_espidf_toolchain.py | 15 +++++++++++++++ tests/unit_tests/test_framework_helpers.py | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index e1688f4170..bb6452acf2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache = _cache().env if version not in env_cache: env_cache[version] = os.environ.copy() + # Do not leak PYTHONPATH into child env + env_cache[version].pop("PYTHONPATH", None) # Use provided IDF framework if available if "IDF_PATH" not in os.environ: diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 86d5e4eaea..b8a43220ff 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -155,6 +155,8 @@ def run_command( _LOGGER.debug("%s - running ...", cmd_str) run_env = os.environ.copy() + # Do not leak PYTHONPATH + run_env.pop("PYTHONPATH", None) if env: run_env.update(env) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 56f358a24c..26d812af8b 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -265,6 +265,21 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) +def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: + """A PYTHONPATH from the parent environment must not reach idf.py. + + It would override the IDF venv's isolation, shadowing its pinned + packages and failing idf.py's dependency check. + """ + toolchain._cache().env.clear() + with patch.dict( + os.environ, + {"IDF_PATH": str(setup_core), "PYTHONPATH": "/outside/site-packages"}, + ): + env = toolchain._get_idf_env(version="5.5.4") + assert "PYTHONPATH" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 7451ee9b39..2022c15bfe 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -188,6 +188,24 @@ def test_run_command_passes_env(mock_subprocess_run: Mock) -> None: assert mock_subprocess_run.call_args[1]["env"]["MY_VAR"] == "42" +def test_run_command_pops_inherited_pythonpath(mock_subprocess_run: Mock) -> None: + """A PYTHONPATH from the parent environment must not leak into subprocesses.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"]) + assert "PYTHONPATH" not in mock_subprocess_run.call_args[1]["env"] + + +def test_run_command_env_pythonpath_preferred_over_pop( + mock_subprocess_run: Mock, +) -> None: + """A PYTHONPATH set explicitly via ``env`` is passed through.""" + mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") + with patch.dict(os.environ, {"PYTHONPATH": "/outside/site-packages"}): + run_command(["cmd"], env={"PYTHONPATH": "/idf/tools"}) + assert mock_subprocess_run.call_args[1]["env"]["PYTHONPATH"] == "/idf/tools" + + def test_run_command_passes_cwd(mock_subprocess_run: Mock, tmp_path: Path) -> None: mock_subprocess_run.return_value = Mock(returncode=0, stdout="", stderr="") run_command(["cmd"], cwd=str(tmp_path)) From 4db47de556375f0554001d03c6c80fdea005db97 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:36:56 +1200 Subject: [PATCH 174/597] Bump version to 2026.8.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a8c77f4bb8..d9421273af 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b2 +PROJECT_NUMBER = 2026.8.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b6770d0001..1a8be98c03 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b2" +__version__ = "2026.8.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From be66e8b99c3aaee6b2ed8b3ab75434ecdfdde612 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:33:47 -0700 Subject: [PATCH 175/597] [ci] Disable CodSpeed benchmarks job outside esphome/esphome (#18372) --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603e68ad7..026c2ba27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -445,8 +445,12 @@ jobs: - common - determine-jobs if: >- - (github.event_name == 'push' && github.ref_name == 'dev') || - (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + github.repository == 'esphome/esphome' && ( + (github.event_name == 'push' && github.ref_name == 'dev') || + (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') + ) + # CodSpeed benchmarks require a CodSpeed account linked to the repository to run + # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself. steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From e5224e22ae1fd07a284794690db68544f76f1b0c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:18:22 -0700 Subject: [PATCH 176/597] [ci] Compare merge-branch base ref against the default branch (#18385) --- .github/scripts/auto-label-pr/detectors.js | 3 ++- .../auto-label-pr/tests/detectors.test.js | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index bb85ccd681..1d76c18be8 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -70,6 +70,7 @@ async function isStackedPr(github, context) { async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; + const defaultBranch = context.payload.repository.default_branch; if (baseRef === 'release') { labels.add('merging-to-release'); @@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) { } else if (await isStackedPr(github, context)) { // GitHub manages the merge order for a stack, so these are not blocked. labels.add('stacked-pr'); - } else if (baseRef !== 'dev') { + } else if (baseRef !== defaultBranch) { // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index f30ceff8c1..be239e2f1b 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; // Builds a fresh context for detectMergeBranch tests instead of mutating the // shared CONTEXT fixture above (which other describe blocks rely on). -function makeMergeContext(baseRef, { stack } = {}) { +function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) { const pull_request = { number: 1, base: { ref: baseRef } }; if (stack !== undefined) { pull_request.stack = stack; } return { repo: { owner: 'esphome', repo: 'esphome' }, - payload: { pull_request } + payload: { pull_request, repository: { default_branch: defaultBranch } } }; } @@ -136,6 +136,21 @@ describe('detectMergeBranch', () => { assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); assert.equal(state.calls, 1); }); + + it('base ref matches default branch adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('other', { defaultBranch: 'other' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('base ref dev when the default branch is main adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev', { defaultBranch: 'main' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + }); // --------------------------------------------------------------------------- From b178f74e5d6b229293b28bfd2cc78ffb79f5be77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 18:45:23 -0700 Subject: [PATCH 177/597] [core] Save the validated config cache on the first upload or logs run (#18367) --- esphome/__main__.py | 28 +- esphome/compiled_config.py | 77 ++++- esphome/components/esp32/__init__.py | 3 + esphome/components/esp8266/__init__.py | 3 + esphome/components/libretiny/__init__.py | 3 + esphome/components/nrf52/__init__.py | 3 + esphome/components/rp2/__init__.py | 3 + esphome/storage_json.py | 56 +++- .../fixtures/lazy_imports/_storage.py | 11 +- tests/unit_tests/test_compiled_config.py | 299 ++++++++++++++++-- tests/unit_tests/test_download_types.py | 52 +++ tests/unit_tests/test_storage_json.py | 99 ++++++ 12 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 tests/unit_tests/test_download_types.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 1262a4525e..c1e05d2ea7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2732,7 +2732,8 @@ def run_esphome(argv): conf_path.name, ) - if config is None: + cache_missed = config is None + if cache_missed: from esphome.config import read_config config = read_config( @@ -2741,26 +2742,25 @@ def run_esphome(argv): # Snapshot only needed by `esphome config --no-defaults`. snapshot_user_config=getattr(args, "no_defaults", False), ) - # Refresh the cache so the next upload/logs hits the fast path - # instead of re-running read_config. Skip when the storage - # sidecar is absent (no compile has run): the cache would - # never be loaded back, so writing secrets to disk is wasted. - if cache_eligible and config is not None: - from esphome.compiled_config import save_compiled_config - from esphome.storage_json import ext_storage_path - - if ext_storage_path(conf_path.name).exists(): - save_compiled_config(config) - if config is None: - return 2 + if config is None: + return 2 CORE.config = config # Fallback for platforms whose validators didn't set the toolchain # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. + # other platforms only support PlatformIO today. Must run before the + # cache refresh below so its sidecar records the same toolchain a + # compile would. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. + if cache_eligible and cache_missed: + from esphome.compiled_config import save_compiled_config_and_sidecar + + save_compiled_config_and_sidecar(config) + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index 303af99e66..be03eea965 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -18,9 +18,9 @@ from pathlib import Path from typing import Any from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.helpers import write_file -from esphome.storage_json import StorageJSON, ext_storage_path +from esphome.storage_json import StorageJSON, ext_storage_path, storage_path from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None: # non-basic dict key), so every upload/logs pays the slow path. _LOGGER.warning("Cannot cache the validated config: %s", err) except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) + # Likely persistent (permissions, full disk): every upload/logs + # pays the slow path until it clears, so surface it. + _LOGGER.warning("Skipping compiled config cache write: %s", err) + + +def save_compiled_config_and_sidecar(config: ConfigType) -> None: + """Refresh the cache from the upload/logs fallback (CORE.config must be set). + + The cache is only written when a complete sidecar is on disk: + load_compiled_config can't use it otherwise, and it holds resolved + secrets. + """ + if _refresh_sidecar(): + save_compiled_config(config) + + +def _refresh_sidecar() -> bool: + """Ensure a complete sidecar is on disk; True when one is. + + Writes one (without claiming a build) when missing or wizard-only. + Failures are non-fatal; the next upload/logs pays the slow path again. + """ + try: + path = storage_path() + try: + old = StorageJSON.load_strict(path) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Present but unreadable: it may hold a real build's metadata, + # and a fresh rewrite would also stop the next compile from + # cleaning a possibly incoherent build tree. + _LOGGER.warning( + "Not caching: storage sidecar %s is unreadable (%s)", path, err + ) + return False + if old is not None and old.can_apply_to_core(): + # Compile-written; nothing to refresh. + return True + if CORE.build_path is not None and CORE.build_path.exists(): + # An unvalidated build tree: its absent or mismatched sidecar + # is what makes the next compile wipe it, so don't vouch for + # a build this run never saw. + _LOGGER.warning( + "Not caching: build tree %s has no matching sidecar; " + "'esphome compile' will settle it", + CORE.build_path, + ) + return False + new = StorageJSON.from_esphome_core(CORE, old, claim_build=False) + if not new.can_apply_to_core(): + _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete") + return False + new.save(path) + return True + except (OSError, EsphomeError) as err: + # write_file wraps OSError into EsphomeError. Persistent + # (unwritable storage dir), so surface that every upload/logs + # pays the slow path. + _LOGGER.warning("Could not refresh the storage sidecar: %s", err) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + # A structural bug; keep the traceback so it isn't mistaken + # for the I/O failure above. + _LOGGER.warning( + "Unexpected error refreshing the storage sidecar", exc_info=True + ) + return False def load_compiled_config(conf_path: Path) -> ConfigType | None: @@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None: return None storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - return None - # apply_to_core assumes a real compile wrote the sidecar; wizard-only - # sidecars leave both of these unset and can't drive upload/logs. - if not storage.core_platform and not storage.target_platform: + if storage is None or not storage.can_apply_to_core(): + _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete") return None storage.apply_to_core() return config diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..7263571d69 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -570,6 +570,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Factory format (Previously Modern)", diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1f7159919d..2161a902cb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -113,6 +113,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Standard format", diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c51af373b3..c56cc48055 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -182,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [ { "title": "UF2 package (recommended)", diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 386fed5412..2d25558254 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -473,6 +473,9 @@ def copy_files() -> None: def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Get the download types for the firmware.""" + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 87e78003ed..60fcd4f8b0 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -156,6 +156,9 @@ def get_download_types(storage_json): the shape stable so the download panel doesn't have to special-case per-platform schemas. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "UF2 factory format", diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a90a36b848..9219914529 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -71,8 +71,11 @@ def archive_storage_path() -> Path: def _to_path_if_not_none(value: str | None) -> Path | None: - """Convert a string to Path if it's not None.""" - return Path(value) if value is not None else None + """Convert a string to Path; None and the legacy "None" both map to None. + + Sidecars written before as_dict skipped unset paths hold str(None). + """ + return Path(value) if value is not None and value != "None" else None def _parse_framework_version(framework_version: str) -> Version: @@ -170,8 +173,10 @@ class StorageJSON: "address": self.address, "web_port": self.web_port, "esp_platform": self.target_platform, - "build_path": str(self.build_path), - "firmware_bin_path": str(self.firmware_bin_path), + "build_path": str(self.build_path) if self.build_path else None, + "firmware_bin_path": ( + str(self.firmware_bin_path) if self.firmware_bin_path else None + ), "loaded_integrations": sorted(self.loaded_integrations), "loaded_platforms": sorted(self.loaded_platforms), "no_mdns": self.no_mdns, @@ -189,7 +194,18 @@ class StorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: + def from_esphome_core( + esph: CoreType, old: StorageJSON | None, *, claim_build: bool = True + ) -> StorageJSON: + """Build a sidecar from post-validation CORE state. + + claim_build=False (the upload/logs fallback, which runs no build) + carries the build-artifact fields (esphome_version, + firmware_bin_path) from *old* instead of asserting this run built + firmware. Validation-derived fields (platform, framework_version, + toolchain, build_path) always stamp; storage_should_clean compares + them against the next compile. + """ hardware = esph.target_platform.upper() framework_version: str | None = None if esph.is_esp32: @@ -204,13 +220,21 @@ class StorageJSON: name=esph.name, friendly_name=esph.friendly_name, comment=esph.comment, - esphome_version=const.__version__, + esphome_version=( + const.__version__ + if claim_build + else (old.esphome_version if old else None) + ), src_version=1, address=esph.address, web_port=esph.web_port, target_platform=hardware, build_path=esph.build_path, - firmware_bin_path=esph.firmware_bin, + firmware_bin_path=( + esph.firmware_bin + if claim_build + else (old.firmware_bin_path if old else None) + ), loaded_integrations=esph.loaded_integrations, loaded_platforms=esph.loaded_platforms, no_mdns=( @@ -302,11 +326,27 @@ class StorageJSON: except Exception: # noqa: BLE001 # pylint: disable=broad-except return None + @staticmethod + def load_strict(path: Path) -> StorageJSON | None: + """Like load, but None only means missing; an unreadable file raises.""" + if not path.is_file(): + return None + return StorageJSON._load_impl(path) + + def can_apply_to_core(self) -> bool: + """True when the sidecar carries everything apply_to_core hands CORE. + + Wizard-written sidecars leave build_path unset (older wizards also + the platform fields) and can't drive upload/logs. + """ + return bool((self.core_platform or self.target_platform) and self.build_path) + def apply_to_core(self) -> None: """Populate CORE with the metadata upload/logs read. Inverse of :meth:`from_esphome_core`. Keep paired -- a new - attribute upload/logs needs has to be captured there too. + attribute upload/logs needs has to be captured there too and + reflected in :meth:`can_apply_to_core`. Validator-only fields (loaded_integrations/platforms, friendly_name) are skipped; the fast path doesn't run validation and CORE.__init__ defaults them. diff --git a/tests/unit_tests/fixtures/lazy_imports/_storage.py b/tests/unit_tests/fixtures/lazy_imports/_storage.py index 969528304b..94acd2e93a 100644 --- a/tests/unit_tests/fixtures/lazy_imports/_storage.py +++ b/tests/unit_tests/fixtures/lazy_imports/_storage.py @@ -1,10 +1,15 @@ """Shared storage-sidecar factory for the lazy-import fixture scripts.""" +from pathlib import Path + from esphome.storage_json import StorageJSON def make_storage() -> StorageJSON: - """A minimal post-compile esp32 sidecar the upload/logs fast path accepts.""" + """A minimal post-compile esp32 sidecar the upload/logs fast path accepts. + + build_path must be set: the fast path rejects sidecars without one. + """ return StorageJSON( storage_version=1, name="test", @@ -15,8 +20,8 @@ def make_storage() -> StorageJSON: address="1.2.3.4", web_port=None, target_platform="ESP32S3", - build_path=None, - firmware_bin_path=None, + build_path=Path("/build/test"), + firmware_bin_path=Path("/build/test/firmware.bin"), loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index b3c2170c3f..77690a6897 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from ipaddress import IPv4Address, IPv4Network import json import os @@ -19,6 +20,7 @@ from esphome.compiled_config import ( compiled_config_path, load_compiled_config, save_compiled_config, + save_compiled_config_and_sidecar, ) from esphome.const import ( CONF_API, @@ -31,7 +33,16 @@ from esphome.const import ( KEY_VARIANT, Toolchain, ) -from esphome.core import CORE, ID, HexInt, Lambda, MACAddress, TimePeriodMilliseconds +from esphome.core import ( + CORE, + ID, + EsphomeError, + HexInt, + Lambda, + MACAddress, + TimePeriodMilliseconds, +) +from esphome.storage_json import StorageJSON from esphome.util import OrderedDict _VALIDATED_CONFIG = { @@ -54,8 +65,9 @@ def _cache_body(config: dict | None = None) -> str: def _write_storage( storage_path: Path, *, - esp_platform: str = "ESP32", + esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", + build_path: str | None = "/build/lite_test", ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -69,7 +81,7 @@ def _write_storage( "address": "192.168.1.42", "web_port": None, "esp_platform": esp_platform, - "build_path": "/build/lite_test", + "build_path": build_path, "firmware_bin_path": "/build/lite_test/firmware.bin", "loaded_integrations": ["api", "logger", "ota", "wifi"], "loaded_platforms": [], @@ -359,31 +371,262 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() -def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( - tmp_path: Path, -) -> None: - """Without a StorageJSON sidecar (no compile has run), the fallback - skips the cache write -- load_compiled_config requires the sidecar, - so writing the rendered (secret-resolved) config would be inert and - leak secrets to disk for nothing.""" +def _storage_fixture(tmp_path: Path) -> StorageJSON: + """A loaded StorageJSON instance matching _write_storage's contents.""" + fixture = tmp_path / "fixture_storage.json" + _write_storage(fixture) + return StorageJSON.load(fixture) + + +def _bare_yaml(tmp_path: Path) -> Path: + """A minimal YAML with CORE.config_path pointed at it.""" yaml_path = tmp_path / "lite_test.yaml" yaml_path.write_text("esphome:\n name: lite_test\n") CORE.config_path = yaml_path + return yaml_path + +@contextmanager +def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any: + """Patch the fallback path's collaborators for a run_esphome call. + + Without kwargs, from_esphome_core stays real (yielded mock is None). + """ with ( patch( "esphome.config.read_config", return_value={"esphome": {"name": "lite_test"}}, - ), - patch("esphome.compiled_config.save_compiled_config") as mock_save, + ) as mock_read, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", - {"upload": lambda args, config: 0}, + {command: lambda args, config: 0}, ), ): - run_esphome(["esphome", "upload", str(yaml_path)]) + if not from_core_kwargs: + yield mock_read, None + return + with patch.object( + StorageJSON, "from_esphome_core", **from_core_kwargs + ) as mock_from_core: + yield mock_read, mock_from_core + + +@pytest.mark.parametrize("command", ["upload", "logs"]) +def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar( + tmp_path: Path, command: str +) -> None: + """A never-compiled config caches on its first upload/logs run: the + fallback writes the StorageJSON sidecar itself (load_compiled_config + needs it), so the second run hits the fast path.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as ( + mock_read, + mock_from_core, + ): + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_from_core.assert_called_once() + assert (storage_dir / "lite_test.yaml.validated.json").exists() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None + # No compile happened, so the sidecar must not claim one. + assert mock_from_core.call_args.kwargs == {"claim_build": False} + + # The second run loads the cache instead of re-validating. + assert run_esphome(["esphome", command, str(yaml_path)]) == 0 + mock_read.assert_called_once() + + +# as_dict serialized unset paths as str(None) until 2026.9; files +# written by those wizards are still on disk. +_WIZARD_SIDECAR_CASES = pytest.mark.parametrize( + "wizard_kwargs", + [ + {"esp_platform": None, "core_platform": None, "build_path": None}, + {"build_path": None}, + {"build_path": "None"}, + ], + ids=["legacy_wizard", "modern_wizard", "none_string_wizard"], +) + + +def _prime_core(tmp_path: Path) -> None: + """Set the post-validation CORE state from_esphome_core reads.""" + CORE.name = "lite_test" + CORE.build_path = tmp_path / "build" / "lite_test" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + +@_WIZARD_SIDECAR_CASES +def test_run_esphome_fallback_completes_wizard_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar can't drive the fast path (no build_path; + older wizards also no platform fields); the fallback rewrites it from + CORE so the cache loads on the next run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_called_once() + storage = StorageJSON.load(storage_dir / "lite_test.yaml.json") + assert storage is not None and storage.core_platform == "esp32" + # What the wizard recorded about a build (nothing, or a real one) + # carries through instead of being stamped with this run's values. + assert storage.esphome_version == "2026.1.0" + assert load_compiled_config(yaml_path) is not None + + +def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails( + tmp_path: Path, +) -> None: + """A failed sidecar write is non-fatal and skips the cache save too: + without the sidecar the cache could never be loaded back, so writing + it would only leave resolved secrets on disk.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(side_effect=RuntimeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 mock_save.assert_not_called() + assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists() + + +def test_run_esphome_fallback_write_failure_takes_io_branch( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """StorageJSON.save raises EsphomeError (write_file wraps OSError into + it), which must land in the plain I/O warning, not the traceback + branch for structural bugs.""" + yaml_path = _bare_yaml(tmp_path) + + with ( + _fallback_run(return_value=_storage_fixture(tmp_path)), + patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")), + patch("esphome.compiled_config.save_compiled_config") as mock_save, + caplog.at_level("WARNING", logger="esphome.compiled_config"), + ): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_save.assert_not_called() + assert "Could not refresh the storage sidecar" in caplog.text + assert "Unexpected error" not in caplog.text + + +def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None: + """A present-but-corrupt sidecar is not overwritten: it may hold a real + build's metadata, and replacing it would suppress the next compile's + clean of a possibly incoherent build tree. The cache save is skipped.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + sidecar = storage_dir / "lite_test.yaml.json" + sidecar.parent.mkdir(parents=True, exist_ok=True) + sidecar.write_text("{truncated", encoding="utf-8") + + with _fallback_run(return_value=None) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert sidecar.read_text(encoding="utf-8") == "{truncated" + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete( + tmp_path: Path, +) -> None: + """If the rebuilt sidecar would still be incomplete, nothing is written: + the cache could never be loaded back, so saving it would only rewrite + resolved secrets on every run.""" + yaml_path = _bare_yaml(tmp_path) + storage_dir = tmp_path / ".esphome" / "storage" + + incomplete = tmp_path / "incomplete_storage.json" + _write_storage(incomplete, build_path=None) + + with _fallback_run(return_value=StorageJSON.load(incomplete)): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + assert not (storage_dir / "lite_test.yaml.json").exists() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + + +def test_run_esphome_fallback_sidecar_records_platformio_toolchain( + tmp_path: Path, +) -> None: + """The toolchain fallback runs before the sidecar write, so platforms + whose validators leave CORE.toolchain unset record the same + "platformio" a compile writes, not null.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + assert CORE.toolchain is None + + with _fallback_run(): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.toolchain == "platformio" + + +@pytest.mark.parametrize("existing_sidecar", [None, "wizard"]) +def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists( + tmp_path: Path, existing_sidecar: str | None +) -> None: + """An existing build tree with a missing or wizard-only sidecar keeps + it that way: the mismatch is what makes the next compile wipe the + unknown tree, so the fallback writes nothing and skips the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.build_path.mkdir(parents=True) + storage_dir = tmp_path / ".esphome" / "storage" + if existing_sidecar == "wizard": + _write_storage(storage_dir / "lite_test.yaml.json", build_path=None) + wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + + with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core): + assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0 + + mock_from_core.assert_not_called() + assert not (storage_dir / "lite_test.yaml.validated.json").exists() + if existing_sidecar == "wizard": + sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8") + assert sidecar_body == wizard_body + else: + assert not (storage_dir / "lite_test.yaml.json").exists() + + +def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None: + """Drive the real from_esphome_core on the fallback path: the + post-validation CORE state yields a complete, loadable sidecar.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + + save_compiled_config_and_sidecar(CORE.config) + + storage = StorageJSON.load( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json" + ) + assert storage is not None + assert storage.core_platform == "esp8266" + assert storage.build_path is not None + # No compile happened, so the sidecar must not claim one. + assert storage.esphome_version is None + assert storage.firmware_bin_path is None + assert load_compiled_config(yaml_path) is not None @pytest.mark.parametrize("command", ["upload", "logs"]) @@ -409,6 +652,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( patch( "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config ) as mock_save, + patch.object(StorageJSON, "from_esphome_core") as mock_from_core, patch.dict( "esphome.__main__.POST_CONFIG_ACTIONS", {command: lambda args, config: 0}, @@ -417,6 +661,8 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( assert run_esphome(["esphome", command, str(yaml_path)]) == 0 mock_save.assert_called_once_with(fresh_config) + # The compile-written sidecar is complete; the fallback leaves it alone. + mock_from_core.assert_not_called() # mtime is now newer than the source YAML, so a follow-up call hits # the fast path instead of repeating read_config. assert cache.stat().st_mtime >= yaml_path.stat().st_mtime @@ -647,24 +893,15 @@ def test_int_keys_coerce_to_strings(primed_storage: Path) -> None: assert config["table"] == {"1": "a", "2": "b"} -def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None: - """A wizard-only sidecar (no compile -- no core_platform / target_platform) - can't drive upload/logs, so the fast path falls back.""" - yaml_path = tmp_path / "lite_test.yaml" - yaml_path.write_text("esphome:\n name: lite_test\n") - CORE.config_path = yaml_path - +@_WIZARD_SIDECAR_CASES +def test_load_compiled_config_rejects_wizard_only_sidecar( + tmp_path: Path, wizard_kwargs: dict[str, Any] +) -> None: + """A wizard-written sidecar (no build_path; older wizards also no + platform fields) can't drive upload/logs, so the fast path falls back.""" + yaml_path = _bare_yaml(tmp_path) storage_dir = tmp_path / ".esphome" / "storage" - storage_dir.mkdir(parents=True, exist_ok=True) - # StorageJSON with both core_platform and target_platform unset. - (storage_dir / "lite_test.yaml.json").write_text( - '{"storage_version": 1, "name": "lite_test", "friendly_name": null, ' - '"comment": null, "esphome_version": null, "src_version": 1, ' - '"address": null, "web_port": null, "esp_platform": null, ' - '"build_path": null, "firmware_bin_path": null, ' - '"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, ' - '"framework": null, "core_platform": null}' - ) + _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs) cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json") _set_cache_mtime(cache_path, yaml_path, offset=5) diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py new file mode 100644 index 0000000000..2ccf53f7e3 --- /dev/null +++ b/tests/unit_tests/test_download_types.py @@ -0,0 +1,52 @@ +"""Platform get_download_types contract for never-built configs. + +Wizard-written and upload/logs-fallback sidecars record no +firmware_bin_path; the download panel must get an empty list for them, +not entries pointing at files that were never built. +""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON + +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] + + +def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: + return import_module(f"esphome.components.{platform}").get_download_types(storage) + + +def _wizard_storage() -> StorageJSON: + return StorageJSON.from_wizard( + name="test_device", + friendly_name="Test Device", + address="test_device.local", + platform="ESP32", + ) + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_no_firmware_path_yields_no_downloads(platform: str) -> None: + """No recorded firmware path means nothing was built; no downloads.""" + assert _download_types(platform, _wizard_storage()) == [] + + +@pytest.mark.parametrize("platform", PLATFORMS) +def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) -> None: + """With a firmware path recorded, every platform offers entries in + the documented title/description/file/download shape.""" + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + + types = _download_types(platform, storage) + + assert types + assert all( + {"title", "description", "file", "download"} <= entry.keys() for entry in types + ) diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 01683507c1..857795d02f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -915,3 +915,102 @@ def test_storage_json_load_area(tmp_path: Path) -> None: legacy = storage_json.StorageJSON.load(legacy_path) assert legacy is not None assert legacy.area is None + + +def test_from_esphome_core_without_claiming_a_build(setup_core: Path) -> None: + """claim_build=False carries the build artifact fields from the old + sidecar while validation-derived fields still stamp from CORE.""" + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = None + mock_core.address = "my_device.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.is_nrf52 = False + mock_core.build_path = "/build/my_device" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "arduino" + mock_core.toolchain = Toolchain.PLATFORMIO + mock_core.area = None + + old = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP8266", + ) + old.esphome_version = "2025.1.0" + old.firmware_bin_path = Path("/old/firmware.bin") + + result = storage_json.StorageJSON.from_esphome_core( + mock_core, old, claim_build=False + ) + + # Build artifact fields carry from the old sidecar, not this run. + assert result.esphome_version == "2025.1.0" + assert result.firmware_bin_path == Path("/old/firmware.bin") + # Validation-derived fields stamp from CORE. + assert result.build_path == "/build/my_device" + assert result.toolchain == "platformio" + assert result.core_platform == "esp8266" + + # With no old sidecar, no build is claimed at all. + bare = storage_json.StorageJSON.from_esphome_core( + mock_core, None, claim_build=False + ) + assert bare.esphome_version is None + assert bare.firmware_bin_path is None + + +def test_load_strict_distinguishes_missing_from_unreadable(tmp_path: Path) -> None: + """load_strict returns None only for a missing file; corrupt raises.""" + assert storage_json.StorageJSON.load_strict(tmp_path / "missing.json") is None + + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{truncated") + with pytest.raises(ValueError): + storage_json.StorageJSON.load_strict(corrupt) + + +def test_as_dict_serializes_unset_paths_as_null(setup_core: Path) -> None: + """Unset build/firmware paths serialize as JSON null, not str(None).""" + storage = storage_json.StorageJSON.from_wizard( + name="wiz", + friendly_name="Wiz", + address="wiz.local", + platform="ESP32", + ) + + result = storage.as_dict() + + assert result["build_path"] is None + assert result["firmware_bin_path"] is None + + +def test_load_treats_legacy_none_string_paths_as_unset(tmp_path: Path) -> None: + """Sidecars written before as_dict emitted null hold str(None); those + must load as unset, not as Path("None").""" + file_path = tmp_path / "legacy_none.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "wiz", + "friendly_name": "Wiz", + "esp_platform": "ESP32", + "core_platform": "esp32", + "build_path": "None", + "firmware_bin_path": "None", + } + ) + ) + + result = storage_json.StorageJSON.load(file_path) + + assert result is not None + assert result.build_path is None + assert result.firmware_bin_path is None From 039b897e7b83267ffe2cee749138b29cf1a5b2cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Aug 2026 18:45:36 -0700 Subject: [PATCH 178/597] [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) --- esphome/components/ethernet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8bdd536ffb..f3c77baaae 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -355,7 +355,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.9.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, From 7cceddb8a34b891681b150a8e45af49d80898228 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:27 +0000 Subject: [PATCH 179/597] Bump bundled esphome-device-builder to 1.10.0 (#18389) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ From 6ed676fe32a35a82f9857fdb2319c18102d1f8cd Mon Sep 17 00:00:00 2001 From: Joppy Furr Date: Sat, 15 Aug 2026 18:14:53 +1200 Subject: [PATCH 180/597] [lvgl] Restore long_press_repeat_time functionality (#18393) --- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index b66a904437..acd5a9bdef 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -444,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); lv_indev_set_disp(this->drv_, parent->get_disp()); lv_indev_set_long_press_time(this->drv_, long_press_time); - // long press repeat time TBD + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); lv_indev_set_user_data(this->drv_, this); lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { auto *l = static_cast(lv_indev_get_user_data(d)); From 5a000cf5e43acbbdd3f9a82e84302094cd9b2e0f Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Sat, 15 Aug 2026 11:16:04 -0700 Subject: [PATCH 181/597] [rotary_encoder] account for min and max value when resetting (#18197) --- esphome/components/rotary_encoder/rotary_encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { From 1add72689222010acbd521d2437260183fb3c731 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:52 -0700 Subject: [PATCH 182/597] Bump bundled esphome-device-builder to 1.11.0 (#18403) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a62eb59a58..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ From de3e657d8bcae1ec1c9298ff869390d77d2e25d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 08:42:47 -0700 Subject: [PATCH 183/597] [platformio] Skip ccache when the binary on PATH fails to run (#18407) --- esphome/platformio/toolchain.py | 32 ++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 48 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} From 646501b0eff760267fd12de74c5fb5d283779eaa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:36:51 -0400 Subject: [PATCH 184/597] [sensor] Pass NaN through the delta filter again (#18400) --- esphome/components/sensor/filter.cpp | 10 +++-- .../fixtures/sensor_filters_delta.yaml | 36 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 38 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index 9d0114e0c4..af8f314f49 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest @@ -25,6 +26,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": [], "filter_zero_delta": [], "filter_percentage": [], + "filter_nan": [], } filter_min_done = loop.create_future() @@ -32,16 +34,23 @@ async def test_sensor_filters_delta( filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() + filter_nan_done = loop.create_future() def on_state(state: EntityState) -> None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" From 2bc4681fd6d54d5959b93e6e5873b35ece42196d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:33:25 +0200 Subject: [PATCH 185/597] [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) --- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..62fd597845 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From c664f5fc951a8ae55eef64f14a382cdfc9e0b3dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 11:06:22 -0700 Subject: [PATCH 186/597] [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) --- esphome/components/bk72xx_ble/__init__.py | 44 ++++++++++++++++--- esphome/components/bk72xx_ble/bdk_scan.cpp | 4 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 22 ++++++---- tests/component_tests/bk72xx_ble/__init__.py | 0 .../bk72xx_ble/config/test_bk7231n.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231q.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231t.yaml | 7 +++ .../bk72xx_ble/config/test_bk7252.yaml | 7 +++ .../bk72xx_ble/test_family_gate.py | 40 +++++++++++++++++ .../config/bk72xx_controller_only.yaml | 2 +- .../config/bk72xx_tracker.yaml | 2 +- 11 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/__init__.py create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7252.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_family_gate.py diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/tests/component_tests/bk72xx_ble/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: From 32c76ae8289326cb2f17d9712db190fc2d599028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:43:27 -0700 Subject: [PATCH 187/597] [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) --- esphome/core/wake/wake_esp8266.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { // Set the wake-requested flag BEFORE esp_schedule so the consumer is // guaranteed to see it on its next gate check. wake_request_set(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } From 801a1817b58909e5bc243b493c53e3e2265ada07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:00 -0700 Subject: [PATCH 188/597] [esp32] Split crash handler addr2line hint per core (#18418) --- esphome/components/esp32/crash_handler.cpp | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 From 3f01f9895f0c98179d8301dbed46aa02801d7f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:49 -0700 Subject: [PATCH 189/597] [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) --- esphome/components/esp32_hosted/__init__.py | 34 ++++++++++++------ .../component_tests/esp32_hosted/__init__.py | 0 .../component_tests/esp32_hosted/test_init.py | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/esp32_hosted/__init__.py create mode 100644 tests/component_tests/esp32_hosted/test_init.py diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index c6a714aace..d3432fb461 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -16,8 +16,10 @@ from esphome.const import ( CONF_VARIANT, ) from esphome.cpp_generator import add_define +from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] +DEPENDENCIES = ["esp32"] # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] @@ -124,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + # The esp_hosted releases compatible with older ESP-IDF versions crash at + # boot with a heap double free in the SDIO RX path (fixed in esp_hosted + # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. + if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0): + raise cv.Invalid( + f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. " + "Remove the framework version from your configuration to use the " + "recommended version, or pin a version at or above 5.3." + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _configure_sdio(config): slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( @@ -251,18 +269,14 @@ async def to_code(config): if config[CONF_USE_PSRAM]: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) - # Library versions + # Library versions; this component set requires ESP-IDF 5.3 or newer, + # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" - if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") - else: - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/tests/component_tests/esp32_hosted/__init__.py b/tests/component_tests/esp32_hosted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py new file mode 100644 index 0000000000..cec81e4e83 --- /dev/null +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -0,0 +1,35 @@ +"""Tests for the esp32_hosted ESP-IDF version gate.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_IDF_VERSION +from esphome.components.esp32_hosted import _final_validate +from esphome.const import PlatformFramework + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"]) +def test_final_validate_accepts_supported_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF 5.3 and newer passes validation unchanged.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + assert _final_validate({}) == {} + + +@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) +def test_final_validate_rejects_old_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF older than 5.3 is rejected with a clear error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"): + _final_validate({}) From 9161f74bb1e58b29f76f92bd5c298adbcbdf728b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:27 +0000 Subject: [PATCH 190/597] Bump bundled esphome-device-builder to 1.10.0 (#18389) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d7ae2cd4ec..a62eb59a58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.9.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 RUN \ platformio settings set enable_telemetry No \ From 46a5665a66873f990398a477dab767c8620e66a1 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Sat, 15 Aug 2026 11:16:04 -0700 Subject: [PATCH 191/597] [rotary_encoder] account for min and max value when resetting (#18197) --- esphome/components/rotary_encoder/rotary_encoder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0831822d86..0734ca87d3 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() { } if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) { - this->store_.counter = 0; + this->store_.counter = std::clamp(0, this->store_.min_value, this->store_.max_value); } int counter = this->store_.counter; if (this->store_.last_read != counter || this->publish_initial_value_) { From dda4566b9e32fd2fab3faa5b7a7335c0bda2fda3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:52 -0700 Subject: [PATCH 192/597] Bump bundled esphome-device-builder to 1.11.0 (#18403) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a62eb59a58..2f23b2f690 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 RUN \ platformio settings set enable_telemetry No \ From ce09504c923a171935d4cb80e598aeaf1cdea1fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 08:42:47 -0700 Subject: [PATCH 193/597] [platformio] Skip ccache when the binary on PATH fails to run (#18407) --- esphome/platformio/toolchain.py | 32 ++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 48 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 0e7ffce939..08a4fcff78 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re import shutil +import subprocess import sys from typing import TYPE_CHECKING, Any @@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) +def _ccache_usable() -> bool: + """Return True when the ``ccache`` on PATH actually runs. + + ``shutil.which`` proves existence, not runnability: on Windows it also + matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose + target is gone. Wrapping compiles around such a find fails every compile + step with an opaque OS error, so probe once and fall back to compiling + without ccache when the probe fails. + """ + ccache = shutil.which("ccache") + if ccache is None: + return False + try: + subprocess.run( + [ccache, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + _LOGGER.warning( + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ccache, + ) + return False + return True + + def _ccache_env() -> dict[str, str]: """Return ccache settings for PlatformIO builds. @@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]: if "ESPHOME_CCACHE_ENABLE" in os.environ: enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") else: - enabled = shutil.which("ccache") is not None + enabled = _ccache_usable() env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} if not enabled: return env diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 02c11b4e45..eebb0b8cd7 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import subprocess import sys import threading from types import SimpleNamespace @@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: assert env == {"ESPHOME_CCACHE_ENABLE": "0"} +@pytest.mark.parametrize( + "probe_error", + [ + pytest.param(OSError("not runnable"), id="oserror"), + pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"), + pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"), + ], +) +def test_ccache_env_disabled_when_probe_fails( + setup_core: Path, probe_error: Exception +) -> None: + """A ccache that resolves on PATH but fails to run stays disabled.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run", side_effect=probe_error), + ): + env = toolchain._ccache_env() + + assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + + +def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: + """An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary.""" + CORE.build_path = setup_core / "build" / "test" + + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + mock_probe.assert_not_called() + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): env = toolchain._ccache_env() @@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env( """A caller-supplied env is the base and gains the ccache settings.""" CORE.build_path = str(setup_core / "build" / "test") - with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"): + with ( + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch.object(toolchain.subprocess, "run"), + ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( "test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"} From bca72e9b6d7d6a4bebff6da0a946e952aef081e5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:36:51 -0400 Subject: [PATCH 194/597] [sensor] Pass NaN through the delta filter again (#18400) --- esphome/components/sensor/filter.cpp | 10 +++-- .../fixtures/sensor_filters_delta.yaml | 36 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 38 +++++++++++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 5f7f19769a..0105580d26 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } optional DeltaFilter::new_value(float value) { - // Always yield the first value. - if (std::isnan(this->last_value_)) { + const bool no_value = std::isnan(value); + const bool no_reference = std::isnan(this->last_value_); + if (no_value && no_reference) + return {}; + if (no_value || no_reference) { this->last_value_ = value; return value; } @@ -293,8 +296,7 @@ optional DeltaFilter::new_value(float value) { float min = fabsf(this->min_a0_ + ref * this->min_a1_); float max = fabsf(this->max_a0_ + ref * this->max_a1_); float delta = fabsf(value - ref); - // if there is no reference, e.g. for the first value, just accept this one, - // otherwise accept only if within range. + // accept only if within range if (delta > min && delta <= max) { this->last_value_ = value; return value; diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index 2494a430da..b01c8e452b 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -33,6 +33,11 @@ sensor: id: source_sensor_5 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 6" + id: source_sensor_6 + accuracy_decimals: 1 + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -81,6 +86,13 @@ sensor: filters: - delta: 50% + - platform: copy + source_id: source_sensor_6 + name: "Filter NaN" + id: filter_nan + filters: + - delta: 0 + script: - id: test_filter_min then: @@ -188,6 +200,24 @@ script: id: source_sensor_5 state: 250.0 # Passes (delta=90 > 80) + - id: test_filter_nan + then: + - sensor.template.publish: + id: source_sensor_6 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: !lambda "return NAN;" # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_6 + state: 2.0 + button: - platform: template name: "Test Filter Min" @@ -218,3 +248,9 @@ button: id: btn_filter_percentage on_press: - script.execute: test_filter_percentage + + - platform: template + name: "Test Filter NaN" + id: btn_filter_nan + on_press: + - script.execute: test_filter_nan diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index 9d0114e0c4..af8f314f49 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import math from aioesphomeapi import ButtonInfo, EntityState, SensorState import pytest @@ -25,6 +26,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": [], "filter_zero_delta": [], "filter_percentage": [], + "filter_nan": [], } filter_min_done = loop.create_future() @@ -32,16 +34,23 @@ async def test_sensor_filters_delta( filter_baseline_max_done = loop.create_future() filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() + filter_nan_done = loop.create_future() def on_state(state: EntityState) -> None: - if not isinstance(state, SensorState) or state.missing_state: + if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) if sensor_name not in sensor_values: return - sensor_values[sensor_name].append(state.state) + if state.missing_state: + # Only the NaN test is interested in unavailable states + if sensor_name != "filter_nan": + return + sensor_values[sensor_name].append(math.nan) + else: + sensor_values[sensor_name].append(state.state) # Check completion conditions if ( @@ -74,6 +83,12 @@ async def test_sensor_filters_delta( and not filter_percentage_done.done() ): filter_percentage_done.set_result(True) + elif ( + sensor_name == "filter_nan" + and len(sensor_values[sensor_name]) == 3 + and not filter_nan_done.done() + ): + filter_nan_done.set_result(True) async with ( run_compiled(yaml_config), @@ -89,6 +104,7 @@ async def test_sensor_filters_delta( "filter_baseline_max": "Filter Baseline Max", "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", + "filter_nan": "Filter NaN", }, ) @@ -108,13 +124,14 @@ async def test_sensor_filters_delta( "Test Filter Baseline Max": "filter_baseline_max", "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", + "Test Filter NaN": "filter_nan", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}" + assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -186,3 +203,18 @@ async def test_sensor_filters_delta( assert sensor_values["filter_percentage"] == pytest.approx(expected), ( f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}" ) + + # Test 6: NaN passes through once, then is suppressed + sensor_values["filter_nan"].clear() + client.button_command(buttons["filter_nan"]) + try: + await asyncio.wait_for(filter_nan_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}") + + values = sensor_values["filter_nan"] + assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}" + assert math.isnan(values[1]), ( + f"Test 6 failed: NaN not passed through, got {values}" + ) + assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" From 594c12b3d961a20576b2425e75d4d05f18fc1993 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:33:25 +0200 Subject: [PATCH 195/597] [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) --- esphome/components/zigbee/zigbee_esp32.cpp | 5 +++++ esphome/components/zigbee/zigbee_esp32.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 482995e2c5..cd094306f4 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -307,6 +307,11 @@ void ZigbeeComponent::setup() { return; } #endif + +#ifdef CONFIG_ZB_ZCZR + ezb_bdb_set_router_rejoin_required(true); +#endif + ezb_aps_secur_enable_distributed_security(false); ezb_nwk_set_min_join_lqi(32); if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 8e63c09e67..ade45e8cc3 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -285,7 +285,7 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.3", + ref="2.0.4", ) # add sdkconfigs later so they can overwrite esp32 defaults diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index aff1a6819f..62fd597845 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -48,7 +48,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.3 + version: 2.0.4 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: From 0bc2d7137078ccb28aa3a8fc8ddbb4ae100a3c52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 11:06:22 -0700 Subject: [PATCH 196/597] [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) --- esphome/components/bk72xx_ble/__init__.py | 44 ++++++++++++++++--- esphome/components/bk72xx_ble/bdk_scan.cpp | 4 +- esphome/components/bk72xx_ble/bk72xx_ble.cpp | 22 ++++++---- tests/component_tests/bk72xx_ble/__init__.py | 0 .../bk72xx_ble/config/test_bk7231n.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231q.yaml | 7 +++ .../bk72xx_ble/config/test_bk7231t.yaml | 7 +++ .../bk72xx_ble/config/test_bk7252.yaml | 7 +++ .../bk72xx_ble/test_family_gate.py | 40 +++++++++++++++++ .../config/bk72xx_controller_only.yaml | 2 +- .../config/bk72xx_tracker.yaml | 2 +- 11 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/__init__.py create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7252.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_family_gate.py diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 23f3d06184..b58464a1f6 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in +to_code; unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. No framework patch is needed: the LibreTiny beken-72xx builder already compiles and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; @@ -21,9 +21,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] @@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema( request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + return None + + +def _final_validate(config: ConfigType) -> ConfigType: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index bd4e51d9b7..f17f21c06b 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -10,7 +10,7 @@ #ifdef USE_BK72XX_BLE // Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). -#if !defined(CLANG_TIDY) && __has_include("ble_api.h") +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") extern "C" { #include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, @@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { } // namespace esphome::bk72xx_ble -#endif // !CLANG_TIDY && ble_api.h +#endif // !CLANG_TIDY && ble_api.h && app_ble.h #endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index d40f08d111..52401114e6 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -34,22 +34,26 @@ // --------------------------------------------------------------------------- // SDK-capability gate (not a chip allowlist). -// This component drives the Beken BLE *5.x* controller via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". // --------------------------------------------------------------------------- #if defined(CLANG_TIDY) // The clang-tidy environment does not carry the full Beken BDK BLE 5.x API // (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing // accurate to analyze the SDK calls against — skip the file under analysis. #define BK72XX_BLE_NO_SDK -#elif !__has_include("ble_api.h") +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK #error \ - "bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." #endif #ifndef BK72XX_BLE_NO_SDK diff --git a/tests/component_tests/bk72xx_ble/__init__.py b/tests/component_tests/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml new file mode 100644 index 0000000000..772ab93c79 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231n.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-n + +bk72xx: + board: cb2s + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml new file mode 100644 index 0000000000..17fd15b1b4 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231q.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-q + +bk72xx: + board: wa2 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml new file mode 100644 index 0000000000..fec21a6aae --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7231t.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-t + +bk72xx: + board: generic-bk7231t-qfn32-tuya + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml new file mode 100644 index 0000000000..a3290ab50a --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7252.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7252 + +bk72xx: + board: generic-bk7252 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py new file mode 100644 index 0000000000..da67749bb3 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -0,0 +1,40 @@ +"""The non-5.x family rejection lives in to_code (config validation must stay +family-agnostic for the validate-only CI fixtures), so codegen is the only +place it can be pinned.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import EsphomeError + + +@pytest.mark.parametrize( + ("config_file", "match"), + [ + ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), + ("test_bk7252.yaml", "BK7251.*BLE 4.2"), + ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ], +) +def test_unsupported_family_rejected( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + match: str, + caplog: pytest.LogCaptureFixture, +) -> None: + with pytest.raises(EsphomeError, match=match): + generate_main(component_config_path(config_file)) + # Validation itself must not fail (CI validate fixtures run on a BLE 4.2 + # board), but it warns before codegen raises. + assert "cannot compile" in caplog.text + + +def test_ble5_family_generates( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("test_bk7231n.yaml")) + assert "bk72xx_ble::BK72xxBLE" in main_cpp diff --git a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml index 4d4dab0198..7912fceed6 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_controller_only.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-controller bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble: diff --git a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml index 79e9644006..b813e2702e 100644 --- a/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml +++ b/tests/component_tests/ble_device_base/config/bk72xx_tracker.yaml @@ -2,6 +2,6 @@ esphome: name: slotcount-tracker bk72xx: - board: generic-bk7252 + board: cb2s bk72xx_ble_tracker: From f42fe9af297c8a19c63fdaa2ae06aac43748186b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:43:27 -0700 Subject: [PATCH 197/597] [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) --- esphome/core/wake/wake_esp8266.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 7eaaae5293..73b7a38a35 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { // Set the wake-requested flag BEFORE esp_schedule so the consumer is // guaranteed to see it on its next gate check. wake_request_set(); + // Skip the post when a wake was already signalled and not yet consumed by + // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, + // which must not be poked per-byte from the software serial RX ISR (see + // esphome#18409). The flag can stay latched while the loop is awake, which + // is intentional; posts are only needed to cut a suspend short. + if (g_main_loop_woke) + return; g_main_loop_woke = true; esp_schedule(); } From bb7d4c3630bf085c45c6991c8d5964baeb2da832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 12:44:00 -0700 Subject: [PATCH 198/597] [esp32] Split crash handler addr2line hint per core (#18418) --- esphome/components/esp32/crash_handler.cpp | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 1b054dcc49..b61dad7386 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -360,17 +360,6 @@ static bool has_fault_addr() { return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; } -// Append both cores' backtrace addresses to buf; returns the new position. -static int append_all_backtraces(char *buf, int size, int pos) { - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, - s_raw_crash_data.other_reg_frame_count); -#endif - return pos; -} - // The record was captured by a different firmware build (it survives soft // resets, including the OTA reboot), so symbolizing its addresses against the // current ELF would produce misleading symbols. Print them with lowercase @@ -443,11 +432,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - append_all_backtraces(hint, sizeof(hint), pos); + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 From 1ec21a22450393cfe777fc6f3923adaa085ff890 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:22:41 +1200 Subject: [PATCH 199/597] Bump version to 2026.8.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d9421273af..2df6d3ded0 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b3 +PROJECT_NUMBER = 2026.8.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 1a8be98c03..73155e06ee 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b3" +__version__ = "2026.8.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 58d549ed4c53ddc72408a8e18f81c12a3648d30a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:56:37 -0700 Subject: [PATCH 200/597] [api] Move NoiseProtocolId off the connection object (#18420) --- .../components/api/api_frame_helper_noise.cpp | 25 +++++++++++-------- .../components/api/api_frame_helper_noise.h | 3 --- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 225bac51a6..09e3ca2b9e 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { */ APIError APINoiseFrameHelper::init_handshake_() { int err; - memset(&nid_, 0, sizeof(nid_)); - // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; - // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto)); - nid_.pattern_id = NOISE_PATTERN_NN; - nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY; - nid_.dh_id = NOISE_DH_CURVE25519; - nid_.prefix_id = NOISE_PREFIX_STANDARD; - nid_.hybrid_id = NOISE_DH_NONE; - nid_.hash_id = NOISE_HASH_SHA256; - nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; + // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: + // noise_handshakestate_new_by_id copies it, so a member would waste + // 104 bytes per connection, and a static const would sit in RAM on + // ESP8266 (.rodata is DRAM there). + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; - err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); + err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index b0ba9fd01c..46bd366672 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Buffer for noise handshake prologue (released after handshake) APIBuffer prologue_; - // NoiseProtocolId (size depends on implementation) - NoiseProtocolId nid_; - // Group small types together // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) From cf764740cf8c186907edb09354df0e4d95750f1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:56:58 -0700 Subject: [PATCH 201/597] [api] Create the camera image reader lazily (#18421) --- esphome/components/api/api_connection.cpp | 26 +++--- esphome/components/camera/camera.h | 3 +- tests/integration/fixtures/camera_mock.yaml | 19 +++++ .../mock_camera/__init__.py | 28 +++++++ .../mock_camera/mock_camera.cpp | 30 +++++++ .../mock_camera/mock_camera.h | 80 +++++++++++++++++++ tests/integration/test_camera_mock.py | 73 +++++++++++++++++ 7 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 tests/integration/fixtures/camera_mock.yaml create mode 100644 tests/integration/fixtures/external_components/mock_camera/__init__.py create mode 100644 tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp create mode 100644 tests/integration/fixtures/external_components/mock_camera/mock_camera.h create mode 100644 tests/integration/test_camera_mock.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 73b4f3e5bd..2eb8c21c73 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #else #error "No frame helper defined" #endif -#ifdef USE_CAMERA - if (camera::Camera::instance() != nullptr) { - this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; - } -#endif } void APIConnection::start() { @@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() { if (!this->image_reader_) return; + const auto *cam = camera::Camera::instance(); // Send as many chunks as possible without blocking while (this->image_reader_->available()) { if (!this->helper_->can_write_without_blocking()) @@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.key = cam->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); + msg.device_id = cam->get_device_id(); #endif if (!this->send_message(msg)) { @@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() { void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; - if (!this->image_reader_) + if (this->image_reader_ && this->image_reader_->available()) return; - if (this->image_reader_->available()) + if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE)) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) { - this->image_reader_->set_image(std::move(image)); - // Try to send immediately to reduce latency - this->try_send_camera_image_(); + if (!this->image_reader_) { + // Created on the first image this connection will send, so connections + // that never receive one never pay for a reader. Only a registered + // camera's listener can reach this, so instance() is non-null here. + this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; } + this->image_reader_->set_image(std::move(image)); + // Try to send immediately to reduce latency + this->try_send_camera_image_(); } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *camera = static_cast(entity); diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index bf80b42e54..433361d298 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -103,7 +103,8 @@ struct CameraImageSpec { /** Abstract camera base class. Collaborates with API. * 1) API server starts and registers as a listener (add_listener) * to receive new images from the camera. - * 2) New API client connects and creates a new image reader (create_image_reader). + * 2) API connection creates an image reader (create_image_reader) when it receives + * the first image it will send. * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. diff --git a/tests/integration/fixtures/camera_mock.yaml b/tests/integration/fixtures/camera_mock.yaml new file mode 100644 index 0000000000..fa354d341f --- /dev/null +++ b/tests/integration/fixtures/camera_mock.yaml @@ -0,0 +1,19 @@ +esphome: + name: camera-mock-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +mock_camera: + name: Mock Camera + # Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across + # multiple CameraImageResponse chunks and the client must reassemble. + # Must match IMAGE_SIZE in test_camera_mock.py. + image_size: 4096 diff --git a/tests/integration/fixtures/external_components/mock_camera/__init__.py b/tests/integration/fixtures/external_components/mock_camera/__init__.py new file mode 100644 index 0000000000..57aaf07ab9 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core.entity_helpers import setup_entity +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/tests"] +AUTO_LOAD = ["camera"] + +CONF_IMAGE_SIZE = "image_size" + +mock_camera_ns = cg.esphome_ns.namespace("mock_camera") +MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase) + +CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(MockCamera), + cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + var = cg.new_Pvariable(config[CONF_ID]) + await setup_entity(var, config, "camera") + await cg.register_component(var, config) + cg.add(var.set_image_size(config[CONF_IMAGE_SIZE])) diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp new file mode 100644 index 0000000000..64ed6bfe5c --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.cpp @@ -0,0 +1,30 @@ +#include "mock_camera.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mock_camera { + +static const char *const TAG = "mock_camera"; + +void MockCamera::loop() { + uint8_t requesters = this->single_requesters_ | this->stream_requesters_; + if (requesters == 0) + return; + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS) + return; + this->last_frame_ms_ = now; + this->single_requesters_ = 0; + + auto image = std::make_shared(this->image_size_, this->frame_counter_, requesters); + ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_, + requesters); + this->frame_counter_++; + for (auto *listener : this->listeners_) { + listener->on_camera_image(image); + } +} + +void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); } + +} // namespace esphome::mock_camera diff --git a/tests/integration/fixtures/external_components/mock_camera/mock_camera.h b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h new file mode 100644 index 0000000000..bcf40bba67 --- /dev/null +++ b/tests/integration/fixtures/external_components/mock_camera/mock_camera.h @@ -0,0 +1,80 @@ +#pragma once + +#include "esphome/components/camera/camera.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::mock_camera { + +/** Deterministic in-memory camera image. + * Byte i of frame N is (N + i) & 0xFF so tests can validate + * reassembled data from just the first byte. + */ +class MockCameraImage : public camera::CameraImage { + public: + MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters) + : data_(new uint8_t[size]), size_(size), requesters_(requesters) { + for (size_t i = 0; i < size; i++) { + this->data_[i] = static_cast(frame_counter + i); + } + } + uint8_t *get_data_buffer() override { return this->data_.get(); } + size_t get_data_length() override { return this->size_; } + bool was_requested_by(camera::CameraRequester requester) const override { + return (this->requesters_ & (1 << requester)) != 0; + } + + protected: + std::unique_ptr data_; + size_t size_; + uint8_t requesters_; +}; + +class MockCameraImageReader : public camera::CameraImageReader { + public: + void set_image(std::shared_ptr image) override { + this->image_ = std::move(image); + this->offset_ = 0; + } + size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; } + uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; } + void consume_data(size_t consumed) override { this->offset_ += consumed; } + void return_image() override { + this->image_.reset(); + this->offset_ = 0; + } + + protected: + std::shared_ptr image_; + size_t offset_{0}; +}; + +/** Virtual camera producing deterministic frames on request or stream. */ +class MockCamera : public camera::Camera { + public: + void loop() override; + void dump_config() override; + + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } + camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); } + void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); } + void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); } + void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); } + + void set_image_size(uint32_t size) { this->image_size_ = size; } + + protected: + static constexpr uint32_t FRAME_INTERVAL_MS = 50; + + // Members ordered largest to smallest to minimize padding + std::vector listeners_; + uint32_t image_size_{1024}; + uint32_t last_frame_ms_{0}; + uint8_t frame_counter_{0}; + uint8_t single_requesters_{0}; + uint8_t stream_requesters_{0}; +}; + +} // namespace esphome::mock_camera diff --git a/tests/integration/test_camera_mock.py b/tests/integration/test_camera_mock.py new file mode 100644 index 0000000000..6819d7a6d4 --- /dev/null +++ b/tests/integration/test_camera_mock.py @@ -0,0 +1,73 @@ +"""Integration test for the camera API flow using a mock camera platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import CameraInfo, CameraState, EntityState +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Must match image_size in fixtures/camera_mock.yaml +IMAGE_SIZE = 4096 +STREAM_FRAMES = 3 + + +def _verify_frame(data: bytes) -> int: + """Verify the deterministic frame pattern and return the frame counter.""" + assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}" + counter = data[0] + assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), ( + "frame pattern mismatch" + ) + return counter + + +@pytest.mark.asyncio +async def test_camera_mock( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Single-image and stream requests deliver reassembled deterministic frames.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + camera = require_entity(entities, "mock_camera", CameraInfo) + + loop = asyncio.get_running_loop() + images: list[bytes] = [] + single_image: asyncio.Future[None] = loop.create_future() + stream_done: asyncio.Future[None] = loop.create_future() + + def on_state(state: EntityState) -> None: + if not (isinstance(state, CameraState) and state.key == camera.key): + return + images.append(bytes(state.data)) + if not single_image.done(): + single_image.set_result(None) + elif len(images) >= STREAM_FRAMES and not stream_done.done(): + stream_done.set_result(None) + + client.subscribe_states(on_state) + + # Single image request: one complete frame arrives, reassembled + # from multiple chunks (4096 > 1390 byte packets) + client.request_single_image() + await asyncio.wait_for(single_image, timeout=10) + first_counter = _verify_frame(images[0]) + + # Stream request: multiple consecutive frames arrive + images.clear() + client.request_image_stream() + await asyncio.wait_for(stream_done, timeout=10) + + # Frames are distinct, ordered, and fresh per the mock's counter. + # Not exactly consecutive: the API drops frames by design while the + # previous image is still being sent, so allow small gaps. + counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]] + for prev, cur in zip(counters, counters[1:], strict=False): + assert cur != prev, f"duplicate frames: {counters}" + assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}" + assert counters[0] != first_counter, "stream should produce new frames" From e1c279718fafe3884101efdbff3a9d1a1d5ed529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:57:12 -0700 Subject: [PATCH 202/597] [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) --- esphome/components/ld2420/ld2420.cpp | 2 -- esphome/components/ld2420/ld2420.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); From ebb0923362601879742a870d39e114b2279258cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:00 -0500 Subject: [PATCH 203/597] Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 876b13793c..61011f2fbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.2 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 07e8b303b9a4f588285795b841c8ae7061d31712 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:52:56 -0700 Subject: [PATCH 204/597] [ota] Shorten platform backend TAG strings (#18438) --- esphome/components/ota/ota_backend_arduino_libretiny.cpp | 2 +- esphome/components/ota/ota_backend_arduino_rp2.cpp | 2 +- esphome/components/ota/ota_backend_esp8266.cpp | 2 +- esphome/components/ota/ota_backend_esp_idf.cpp | 2 +- esphome/components/ota/ota_backend_host.cpp | 2 +- esphome/components/ota/ota_bootloader_esp_idf.cpp | 2 +- esphome/components/ota/ota_partitions_esp_idf.cpp | 2 +- esphome/components/ota/ota_signature_esp_idf.cpp | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 4cc99202a7..231c4d2dd2 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -9,7 +9,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_libretiny"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index b35eb38c12..48725b1265 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 6a678fb419..2a6a9e08b1 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -46,7 +46,7 @@ static constexpr size_t MIN_BUFFER_SIZE = 256; namespace esphome::ota { -static const char *const TAG = "ota.esp8266"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 108605e4c9..eb23ad82dd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -15,7 +15,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; std::unique_ptr make_ota_backend() { return make_unique(); } diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index ee503a49e1..89e3f99e1e 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -27,7 +27,7 @@ namespace esphome::ota { namespace { -const char *const TAG = "ota.host"; +const char *const TAG = "ota"; constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 264218a3df..57b5529350 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -11,7 +11,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { // Register the bootloader partition diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index a7fc709313..d2b1196de6 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index b327988d2d..71dcc0eb83 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -31,7 +31,7 @@ namespace esphome::ota { -static const char *const TAG = "ota.idf"; +static const char *const TAG = "ota"; // Route the "Signature check: " prefix (and its per-block form) through one // shared format string each, so the prefix is pooled once by the linker instead From c01f24553c129327ef591cb9e7198369918663b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:29 -0700 Subject: [PATCH 205/597] [uart] Shorten platform backend TAG strings (#18439) --- esphome/components/uart/uart_component_esp8266.cpp | 2 +- esphome/components/uart/uart_component_esp_idf.cpp | 2 +- esphome/components/uart/uart_component_host.cpp | 2 +- esphome/components/uart/uart_component_libretiny.cpp | 2 +- esphome/components/uart/uart_component_rp2.cpp | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index fc1509f737..2f8b4dbd11 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -14,7 +14,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_esp8266"; +static const char *const TAG = "uart"; bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 93e43e0372..a61339feb4 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -21,7 +21,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.idf"; +static const char *const TAG = "uart"; /// Check if a pin number matches one of the default UART0 GPIO pins. /// These pins may have residual IOMUX state from the ROM bootloader that diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 5bb7a49726..63b5631564 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -98,7 +98,7 @@ speed_t get_baud(int baud) { namespace esphome::uart { -static const char *const TAG = "uart.host"; +static const char *const TAG = "uart"; HostUartComponent::~HostUartComponent() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index fbf0c20ded..4eacd980db 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -16,7 +16,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.lt"; +static const char *const TAG = "uart"; static const char *const UART_TYPE[] = { "hardware", diff --git a/esphome/components/uart/uart_component_rp2.cpp b/esphome/components/uart/uart_component_rp2.cpp index 9cc3009a22..ffb9bc0f2d 100644 --- a/esphome/components/uart/uart_component_rp2.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -13,7 +13,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2"; +static const char *const TAG = "uart"; uint16_t RP2UartComponent::get_config() { uint16_t config = 0; From d1a7b8df8b616cd49affa5a70298fbdfce07d6be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:42 -0700 Subject: [PATCH 206/597] [adc] Shorten platform TAG strings (#18441) --- esphome/components/adc/adc_sensor_common.cpp | 2 +- esphome/components/adc/adc_sensor_esp32.cpp | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 2 +- esphome/components/adc/adc_sensor_libretiny.cpp | 2 +- esphome/components/adc/adc_sensor_rp2.cpp | 2 +- esphome/components/adc/adc_sensor_zephyr.cpp | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 16c86aee18..5ca58df10e 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.common"; +static const char *const TAG = "adc"; const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index a761b37749..a0f7a1ed08 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.esp32"; +static const char *const TAG = "adc"; adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index e4f2f82f08..77a192e025 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc.esp8266"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index d9b9f50be1..dfa545b395 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.libretiny"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 8652a46029..ce665e8501 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2"; +static const char *const TAG = "adc"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c3632b00e2..bf45059740 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.zephyr"; +static const char *const TAG = "adc"; void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { From 37bea1c1538c830691e95bcce398e816069f7556 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:48 -0700 Subject: [PATCH 207/597] [spi] Shorten platform backend TAG strings (#18442) --- esphome/components/spi/spi_arduino.cpp | 2 +- esphome/components/spi/spi_esp_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index a3e09d2800..14428bed62 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const char *const TAG = "spi-esp-arduino"; +static const char *const TAG = "spi"; class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 0731078eec..d5d5053117 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #ifdef USE_ESP32 -static const char *const TAG = "spi-esp-idf"; +static const char *const TAG = "spi"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. class SPIDelegateHw : public SPIDelegate { From 47a58dd7991affd47b61df0cd491076d77ceff18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:53:56 -0700 Subject: [PATCH 208/597] [internal_temperature] Shorten platform TAG strings (#18443) --- .../internal_temperature/internal_temperature_bk72xx.cpp | 2 +- .../internal_temperature/internal_temperature_esp32.cpp | 2 +- .../internal_temperature/internal_temperature_rp2.cpp | 2 +- .../internal_temperature/internal_temperature_zephyr.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index b7332ee81f..91f47d831f 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.bk72xx"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 64fe3707b1..2c6fda2af4 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.esp32"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 2e408b3b01..c4ab33b0a5 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -16,7 +16,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2"; +static const char *const TAG = "internal_temperature"; // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index be72ab6f51..50c597f6f1 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.zephyr"; +static const char *const TAG = "internal_temperature"; static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); From e0d28d7f5c9128ca98436ec7d4a25cc5ebe91914 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:03 -0700 Subject: [PATCH 209/597] [http_request] Shorten platform backend TAG strings (#18444) --- esphome/components/http_request/http_request_arduino.cpp | 2 +- esphome/components/http_request/http_request_host.cpp | 2 +- esphome/components/http_request/http_request_idf.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 84333e7169..43ab2e5b53 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.arduino"; +static const char *const TAG = "http_request"; #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 85c6e8b3c7..cf231e20bd 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -14,7 +14,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.host"; +static const char *const TAG = "http_request"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a437540241..ddff954950 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.idf"; +static const char *const TAG = "http_request"; static constexpr uint32_t ERROR_DURATION_MS = 1000; void HttpRequestIDF::dump_config() { From 1f000ba66899be59be0aaa655692757111c8f435 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:17 -0700 Subject: [PATCH 210/597] [mqtt] Shorten esp32 backend TAG string (#18446) --- esphome/components/mqtt/mqtt_backend_esp32.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 499a330730..09eb5f97dc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.idf"; +static const char *const TAG = "mqtt"; bool MQTTBackendESP32::initialize_() { mqtt_cfg_.broker.address.hostname = this->host_.c_str(); From 1f4fcead38d9897e37c6c3ec059c86988408e5b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:29 -0700 Subject: [PATCH 211/597] [nextion] Shorten upload TAG strings (#18448) --- esphome/components/nextion/nextion_upload_arduino.cpp | 2 +- esphome/components/nextion/nextion_upload_esp32.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 2f3377d950..f02f32d5ca 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -13,7 +13,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.arduino"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index e2d5ae8ad7..c4dc74b5d3 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload.esp32"; +static const char *const TAG = "nextion.upload"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). From ebe93e2c684c46ea960262c05a0914b5f6bda61e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:35 -0700 Subject: [PATCH 212/597] [bluetooth_connection] Shorten platform TAG strings (#18449) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- .../bluetooth_connection/bluetooth_connection_rp2.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 076c77b18e..15f854239d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -20,7 +20,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.bluedroid"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::FAST_CONN_TIMEOUT; using ble_device_base::FAST_MAX_CONN_INTERVAL; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 855c895196..16a89dcfdd 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -15,7 +15,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection.rp2"; +static const char *const TAG = "bluetooth_connection"; using ble_device_base::ESPBTUUID; using ble_device_base::GATT_ERR_NOT_CONNECTED; From 3d9fecb56229ddefc7eaf246e23d4ed656f28f03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:52 -0700 Subject: [PATCH 213/597] [remote_receiver] Shorten esp32 TAG string (#18447) --- esphome/components/remote_receiver/remote_receiver_rmt.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 596608a4d0..632ca9763a 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -9,7 +9,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver.esp32"; +static const char *const TAG = "remote_receiver"; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; From f6c7434b2abb0cba04ce08e669c14b380b9622bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:54:55 -0700 Subject: [PATCH 214/597] [i2c] Shorten platform backend TAG strings (#18440) --- esphome/components/i2c/i2c_bus_arduino.cpp | 2 +- esphome/components/i2c/i2c_bus_esp_idf.cpp | 2 +- esphome/components/i2c/i2c_bus_host.cpp | 2 +- esphome/components/i2c/i2c_bus_zephyr.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index cc036b12c3..39a6aec774 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -9,7 +9,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.arduino"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 4aca4f0fae..7ca9537e2d 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.idf"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 17279fda50..303944636b 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.host"; +static const char *const TAG = "i2c"; HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 1eb9944dcb..ffdd2ba8bb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.zephyr"; +static const char *const TAG = "i2c"; static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { From 031a038b49318018ca1aeee08cc111c2aa5e9b9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 09:56:37 -0700 Subject: [PATCH 215/597] [deep_sleep] Shorten bk72xx TAG string (#18445) --- esphome/components/deep_sleep/deep_sleep_bk72xx.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 73e0331c76..2c97dc3211 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep.bk72xx"; +static const char *const TAG = "deep_sleep"; #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { From 6d20ebc66b309df4d413d316f369ffc48742f4cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 10:18:43 -0700 Subject: [PATCH 216/597] [socket] Shorten lwip TAG string (#18450) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..b80a394eec 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -43,7 +43,7 @@ namespace esphome::socket { // (Ethernet). On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT -static const char *const TAG = "socket.lwip"; +static const char *const TAG = "socket"; // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) From 27483a4101e2098cefff3a5c7c56d0b1594b2506 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 11:15:31 -0700 Subject: [PATCH 217/597] [core] Retry gh CLI calls on transient network errors in CI scripts (#18292) --- script/ci_memory_impact_comment.py | 24 +++--- script/helpers.py | 91 +++++++++++++++++++++- tests/script/test_helpers.py | 121 +++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 15 deletions(-) diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 0908b99595..33ca84d76c 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -20,17 +20,21 @@ from jinja2 import Environment, FileSystemLoader sys.path.insert(0, str(Path(__file__).parent.parent)) # pylint: disable=wrong-import-position +from helpers import run_gh_command # noqa: E402 # Comment marker to identify our memory impact comments COMMENT_MARKER = "" -def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProcess: - """Run a gh CLI command with error handling. +def run_gh_command_logged( + args: list[str], operation: str, *, retry: bool = True +) -> subprocess.CompletedProcess: + """Run a gh CLI command with retries and error reporting. Args: args: Command arguments (including 'gh') operation: Description of the operation for error messages + retry: Pass False for non-idempotent commands (see run_gh_command) Returns: CompletedProcess result @@ -39,12 +43,7 @@ def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProce subprocess.CalledProcessError: If command fails (with detailed error output) """ try: - return subprocess.run( - args, - check=True, - capture_output=True, - text=True, - ) + return run_gh_command(args, retry=retry) except subprocess.CalledProcessError as e: print( f"ERROR: {operation} failed with exit code {e.returncode}", file=sys.stderr @@ -472,7 +471,7 @@ def find_existing_comment(pr_number: str) -> str | None: print(f"DEBUG: Looking for existing comment on PR #{pr_number}", file=sys.stderr) # Use gh api to get comments directly - this returns the numeric id field - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -535,7 +534,7 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None: """ print(f"DEBUG: Updating existing comment {comment_id}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + result = run_gh_command_logged( [ "gh", "api", @@ -562,9 +561,12 @@ def create_new_comment(pr_number: str, comment_body: str) -> None: """ print(f"DEBUG: Posting new comment on PR #{pr_number}", file=sys.stderr) print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr) - result = run_gh_command( + # Creating a comment is not idempotent: a retry after a dropped response + # could post the same comment twice, so fail on the first error instead. + result = run_gh_command_logged( ["gh", "pr", "comment", pr_number, "--body", comment_body], operation="Create PR comment", + retry=False, ) print(f"DEBUG: Post response: {result.stdout}", file=sys.stderr) diff --git a/script/helpers.py b/script/helpers.py index 7cc001d92f..11549808ff 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -469,6 +469,77 @@ def get_target_branch() -> str | None: return None +# Substrings (matched case-insensitively against gh's stderr) that identify +# transient failures worth retrying: server errors (HTTP 5xx) and dropped or +# failed connections. Permanent failures (bad auth, missing PR, the 300-file +# diff limit) never match so callers see them immediately. Phrases are +# anchored so gh's GraphQL "Could not resolve to a PullRequest" (a missing +# PR) never classifies as a DNS failure. +_TRANSIENT_GH_ERROR_RE = re.compile( + r"http 5\d\d" + r"|timed out|timeout" + r"|connection (?:reset|refused|closed)" + r"|no such host|could not resolve host" + # gh intercepts DNS errors and prints its own "error connecting to + # " text; the Go phrases above are kept as a hedge in case a + # future gh stops swallowing the underlying error + r"|error connecting to" + r"|failed to verify certificate" + # Go reports a server-closed connection as 'Post "": EOF'; the + # quote-and-colon anchor keeps a URL or message body containing the + # letters from matching + r"|unexpected eof" + r'|": eof' + r"|network is unreachable" + r"|temporary failure" +) + +# Same retry policy as git network commands in esphome/git.py: 3 attempts +# with 2s/4s backoff. +_GH_MAX_ATTEMPTS = 3 + + +def run_gh_command( + args: list[str], *, retry: bool = True +) -> subprocess.CompletedProcess[str]: + """Run a gh CLI command, retrying transient network and server failures. + + Args: + args: Full command line, including the leading "gh". + retry: Pass False for commands that are not idempotent (e.g. posting + a comment), where a retry after a dropped response could repeat + a write that already succeeded server-side. + + Returns: + CompletedProcess with captured text output. + + Raises: + subprocess.CalledProcessError: If the command fails with a permanent + error, or is still failing after the retries are exhausted. + """ + attempts = _GH_MAX_ATTEMPTS if retry else 1 + attempt = 0 + while True: + try: + return subprocess.run( + args, check=True, capture_output=True, text=True, close_fds=False + ) + except subprocess.CalledProcessError as err: + attempt += 1 + stderr = err.stderr or "" + if attempt >= attempts or not _TRANSIENT_GH_ERROR_RE.search(stderr.lower()): + raise + delay = 2**attempt + # Only the leading arguments: comment-update calls carry the + # whole multi-KB comment body in the argument list + print( + f"WARNING: {' '.join(args[:3])} failed: {stderr.strip()}; " + f"retrying in {delay}s (attempt {attempt}/{attempts})", + file=sys.stderr, + ) + time.sleep(delay) + + @cache def _get_changed_files_github_actions() -> list[str] | None: """Get changed files in GitHub Actions environment. @@ -542,10 +613,22 @@ def changed_files(branch: str | None = None) -> list[str]: def _get_changed_files_from_command(command: list[str]) -> list[str]: - """Run a git command to get changed files and return them as a list.""" - proc = subprocess.run(command, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise Exception(f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}") + """Run a git or gh command to get changed files and return them as a list.""" + if command[0] == "gh": + try: + proc = run_gh_command(command) + except subprocess.CalledProcessError as e: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {e.stderr}" + ) from e + else: + proc = subprocess.run( + command, capture_output=True, text=True, check=False, close_fds=False + ) + if proc.returncode != 0: + raise Exception( + f"Command failed: {' '.join(command)}\nstderr: {proc.stderr}" + ) changed_files = splitlines_no_ends(proc.stdout) cwd = Path.cwd() diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 077b6ef23e..a07e56cea5 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -20,6 +20,7 @@ changed_files = helpers.changed_files filter_changed = helpers.filter_changed get_changed_components = helpers.get_changed_components _get_changed_files_from_command = helpers._get_changed_files_from_command +run_gh_command = helpers.run_gh_command _get_pr_number_from_github_env = helpers._get_pr_number_from_github_env _get_changed_files_github_actions = helpers._get_changed_files_github_actions _filter_changed_ci = helpers._filter_changed_ci @@ -1872,3 +1873,123 @@ def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> def test_base_python_changed(files: list[str], expected: bool) -> None: """Only Python modules directly in esphome/ count as base Python changes.""" assert helpers.base_python_changed(files) is expected + + +def _gh_error(stderr: str) -> subprocess.CalledProcessError: + return subprocess.CalledProcessError(1, ["gh"], output="", stderr=stderr) + + +def _gh_success(stdout: str = "ok\n") -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(["gh"], 0, stdout=stdout, stderr="") + + +def test_run_gh_command_success() -> None: + """A successful command returns without retrying.""" + with patch("helpers.subprocess.run", return_value=_gh_success()) as mock_run: + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + mock_run.assert_called_once() + + +@pytest.mark.parametrize( + "second_error", + [ + ( + 'Post "https://api.github.com/graphql": tls: failed to verify' + " certificate: x509: certificate is not valid for any names," + " but wanted to match api.github.com" + ), + 'Post "https://api.github.com/graphql": EOF', + ( + "error connecting to api.github.com\n" + "check your internet connection or https://githubstatus.com" + ), + ], +) +def test_run_gh_command_retries_transient_error(second_error: str) -> None: + """Transient server errors are retried with 2s/4s backoff.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=[ + _gh_error("HTTP 502: 502 Bad Gateway (https://api.github.com/graphql)"), + _gh_error(second_error), + _gh_success(), + ], + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + ): + result = run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert result.stdout == "ok\n" + assert mock_run.call_count == 3 + assert [call.args[0] for call in mock_sleep.call_args_list] == [2, 4] + + +def test_run_gh_command_gives_up_after_max_attempts() -> None: + """A persistent transient error raises after the third attempt.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 503: Service Unavailable"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + assert mock_run.call_count == 3 + assert mock_sleep.call_count == 2 + + +@pytest.mark.parametrize( + "stderr", + [ + "HTTP 404: Not Found (https://api.github.com/repos/x)", + "HTTP 401: Bad credentials", + "HTTP 403: API rate limit exceeded for installation ID 123.", + "diff exceeded the maximum number of changed files (300)", + ( + "GraphQL: Could not resolve to a PullRequest with the number of 999999." + " (repository.pullRequest)" + ), + ], +) +def test_run_gh_command_permanent_error_not_retried(stderr: str) -> None: + """Permanent failures raise immediately without any retry.""" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "diff", "123", "--name-only"]) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_run_gh_command_no_retry_for_non_idempotent_commands() -> None: + """retry=False fails on the first error even when it looks transient.""" + with ( + patch( + "helpers.subprocess.run", + side_effect=_gh_error("HTTP 502: 502 Bad Gateway"), + ) as mock_run, + patch("helpers.time.sleep") as mock_sleep, + pytest.raises(subprocess.CalledProcessError), + ): + run_gh_command(["gh", "pr", "comment", "123", "--body", "x"], retry=False) + + mock_run.assert_called_once() + mock_sleep.assert_not_called() + + +def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None: + """Failures from gh surface stderr so callers can detect the 300-file limit.""" + stderr = "diff exceeded the maximum number of changed files (300)" + with ( + patch("helpers.subprocess.run", side_effect=_gh_error(stderr)), + pytest.raises(Exception, match="maximum number of changed files"), + ): + _get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"]) From a3af82867b3cebfbf6af9a3be9c54731f0e8d544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 12:00:39 -0700 Subject: [PATCH 218/597] [api] Bump noise-c to 0.1.18 (#18451) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..5ca9484336 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + cg.add_library("esphome/noise-c", "0.1.18") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..2c22523be5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 6d20943a9c17a994338ba176c2709de80897d1df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:38:06 -0400 Subject: [PATCH 219/597] Bump esphome/workflows/.github/workflows/stale.yml from 61fd37a044cad4e9aa4303027b2a61b6a34da855 to a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 (#18464) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3c471b6efb..aa31094f81 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main + uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From 9bc72529a6a4dab3bcfb6d182b1a5aa2f0b66b9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:08 -0500 Subject: [PATCH 220/597] Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 in /.github/actions/restore-python (#18462) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index daf041819c..6279a26dc4 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 4712c15c75d0457aea5d75ff717a0f2bc7171d00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:24 -0500 Subject: [PATCH 221/597] Bump github/codeql-action/init from 4.37.6 to 4.37.7 (#18466) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e164cd9f6..f01441cdfd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 3d5f6f692f4916fd2923c490d28a5df3287c087c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:45:41 -0500 Subject: [PATCH 222/597] Bump filelock from 3.32.2 to 3.32.3 (#18461) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61011f2fbd..4b1708637d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.2 # native esp-idf toolchain global cache dir -filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 95180067245bf49d5154889082323c91450145c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:46:04 -0500 Subject: [PATCH 223/597] Bump ruff from 0.16.2 to 0.16.3 (#18458) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 95ee97437d..cedc107b17 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.2 # also change in .pre-commit-config.yaml when updating +ruff==0.16.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating prek==0.4.13 # also change in .github/workflows/ci.yml when updating From 346ba7e831d21d5b005276ca1085fcc570ce3ce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:47:18 -0500 Subject: [PATCH 224/597] Bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#18467) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f01441cdfd..103cecc1f9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: "/language:${{matrix.language}}" From 55726120db6bfaf8ed8fc699a92580f59b7b0e46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:47:28 -0500 Subject: [PATCH 225/597] Bump esphome/workflows/.github/workflows/lock.yml from 2026.7.0 to 2026.8.1 (#18465) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index ec736a2002..e09e9bf2d1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 From 199e368fe2dfca47d8c59796bb54d1d292934396 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:57:50 -0500 Subject: [PATCH 226/597] Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#18463) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 820081cc46..1ccff96f24 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 026c2ba27a..cd1a382c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -367,7 +367,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1095,7 +1095,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a299e76584..9100064176 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pin uv version so the action does not have to fetch the From e32329cc11a38d9b0a70bbd401b1e0ea6a062423 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 15:00:37 -0500 Subject: [PATCH 227/597] [core] Sync pre-commit ruff hook with requirements (0.16.3) (#18469) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99a4f40201..0ea799aa4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.0 + rev: v0.16.3 hooks: # Run the linter. - id: ruff From e45b4e493886e089880cef1b77a4ae367ca0aea9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:26:24 +1200 Subject: [PATCH 228/597] [core] Make FINAL_VALIDATE_SCHEMA functions return None (#18457) --- esphome/components/bk72xx_ble/__init__.py | 3 +-- esphome/components/captive_portal/__init__.py | 4 +--- esphome/components/dsmr/__init__.py | 4 +--- esphome/components/emontx/__init__.py | 4 ++-- esphome/components/epaper_spi/display.py | 3 +-- esphome/components/esp32/__init__.py | 4 +--- esphome/components/esp32_ble/__init__.py | 4 +--- esphome/components/esp32_ble_server/__init__.py | 3 +-- esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/ethernet/__init__.py | 3 +-- esphome/components/factory_reset/__init__.py | 3 +-- esphome/components/file/image.py | 3 +-- .../components/gpio/binary_sensor/__init__.py | 10 ++++------ esphome/components/growatt_solar/sensor.py | 4 ++-- esphome/components/haier/climate.py | 3 +-- esphome/components/haier/switch/__init__.py | 3 +-- esphome/components/havells_solar/sensor.py | 4 ++-- esphome/components/hub75/display.py | 4 +--- esphome/components/improv_serial/__init__.py | 3 +-- esphome/components/inkplate/display.py | 3 +-- esphome/components/it8951/display.py | 3 +-- esphome/components/kuntze/sensor.py | 4 ++-- esphome/components/ld6002b/button/__init__.py | 4 +--- esphome/components/ld6002b/number/__init__.py | 6 ++---- esphome/components/light/__init__.py | 6 ++---- esphome/components/mcp4461/output/__init__.py | 5 ++--- esphome/components/mdns/__init__.py | 5 ++--- esphome/components/mipi_dsi/display.py | 3 +-- esphome/components/mipi_rgb/display.py | 3 +-- esphome/components/mitsubishi_cn105/climate.py | 6 +++--- .../components/modbus_controller/__init__.py | 6 ++---- esphome/components/modbus_server/__init__.py | 4 ++-- .../packet_transport/binary_sensor.py | 6 +++--- esphome/components/provisioning/__init__.py | 3 +-- esphome/components/pzemac/sensor.py | 4 ++-- esphome/components/pzemdc/sensor.py | 4 ++-- esphome/components/router/speaker/__init__.py | 3 +-- esphome/components/rp2040_ble/__init__.py | 3 +-- esphome/components/sdm_meter/sensor.py | 4 ++-- esphome/components/sds011/sensor.py | 3 +-- esphome/components/selec_meter/sensor.py | 4 ++-- esphome/components/tinyusb/__init__.py | 3 +-- esphome/components/web_server/__init__.py | 3 +-- esphome/components/zephyr_pwm/output.py | 3 +-- esphome/components/zwave_proxy/__init__.py | 4 +--- tests/component_tests/esp32_hosted/test_init.py | 2 +- tests/component_tests/image/test_init.py | 17 +++++++++-------- .../provisioning/test_provisioning.py | 6 +++--- 48 files changed, 79 insertions(+), 123 deletions(-) diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index b58464a1f6..81073c9b02 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -68,12 +68,11 @@ def _unsupported_family_message(family: str) -> str | None: return None -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # Warn only: a hard error here would break the validate-only CI fixtures, # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index d62c718097..8e5274f58f 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() wifi_conf = full_config.get("wifi") @@ -88,8 +88,6 @@ def _final_validate(config: ConfigType) -> ConfigType: socket.consume_sockets(3, "captive_portal")(config) socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 34f37ace35..eaf36d34fa 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -88,7 +88,7 @@ async def to_code(config): cg.add_library("esphome/dsmr_parser", "1.9.0") -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() for uart_conf in full_config["uart"]: @@ -102,7 +102,5 @@ def final_validate(config: ConfigType) -> ConfigType: ) break - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index a2d4349698..3f83578926 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -59,7 +59,7 @@ CONFIG_SCHEMA = ( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Count sensors registered to this hub (IDs are resolved at final_validate stage) @@ -95,7 +95,7 @@ def final_validate(config: ConfigType) -> ConfigType: parity="NONE", stop_bits=1, ) - return schema(config) + schema(config) FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 0b82850f1e..e9da924de5 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -153,7 +153,7 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config) -> None: spi.final_validate_device_schema( "epaper_spi", require_miso=False, require_mosi=True )(config) @@ -170,7 +170,6 @@ def _final_validate(config): config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7263571d69..7d43c3ac07 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1368,7 +1368,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: return config -def final_validate(config): +def final_validate(config) -> None: # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN @@ -1629,8 +1629,6 @@ def final_validate(config): if errs: raise cv.MultipleInvalid(errs) - return config - CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 935d8b1b7e..f099c68e57 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -443,7 +443,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config): +def final_validation(config) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -514,8 +514,6 @@ def final_validation(config): # For newer chips (C3/S3/etc), different configs are used automatically add_idf_sdkconfig_option("CONFIG_BTDM_CTRL_BLE_MAX_CONN", max_connections) - return config - FINAL_VALIDATE_SCHEMA = final_validation diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index ea2a9667d7..855a3be29b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -307,7 +307,7 @@ def create_device_information_service(config): return config -def final_validate_config(config): +def final_validate_config(config) -> None: # Validate max_clients does not exceed esp32_ble max_connections max_clients = config[CONF_MAX_CLIENTS] if max_clients > 1: @@ -355,7 +355,6 @@ def final_validate_config(config): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value" ) - return config def validate_value_type(value_config): diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index d3432fb461..7dc61ce382 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -126,7 +126,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # The esp_hosted releases compatible with older ESP-IDF versions crash at # boot with a heap double free in the SDIO RX path (fixed in esp_hosted # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. @@ -136,7 +136,6 @@ def _final_validate(config: ConfigType) -> ConfigType: "Remove the framework version from your configuration to use the " "recommended version, or pin a version at or above 5.3." ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index f3c77baaae..5eda0fc12c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -767,7 +767,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: raise cv.Invalid(error_msg, path=pin_path) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Final validation for Ethernet component.""" # Allow ethernet + wifi coexistence only when both are declared in network: priority:. if "wifi" in fv.full_config.get(): @@ -787,7 +787,6 @@ def _final_validate(config: ConfigType) -> ConfigType: _final_validate_spi(config) _final_validate_rmii_pins(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 818a53c0ed..d5d5d2ecb5 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -60,14 +60,13 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): raise cv.Invalid( "'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index b54c3f2adf..d340d21490 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -225,7 +225,7 @@ def image_schema(class_: MockObjClass = Image_) -> cv.Schema: ) -def validate_image_final(config: ConfigType) -> ConfigType: +def validate_image_final(config: ConfigType) -> None: """Per-entry final validation, shared by file-backed image platforms. For LVGL 9 the default byte order for RGB565 images is little-endian, so @@ -240,7 +240,6 @@ def validate_image_final(config: ConfigType) -> ConfigType: ) else: config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" - return config async def new_image(config: ConfigType) -> MockObj: diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 43358baedb..703806670c 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -68,10 +68,10 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config): +def _final_validate(config) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: - return config + return # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. @@ -82,7 +82,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return pin_num = config[CONF_PIN][CONF_NUMBER] @@ -96,7 +96,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return # When a pin is shared, interrupts can interfere with other components # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. @@ -120,8 +120,6 @@ def _final_validate(config): pin_num, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index d1f0069341..d62486f5ec 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -163,8 +163,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("growatt_solar", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 424ef46392..70ae36f528 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -424,7 +424,7 @@ async def power_action_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if CONF_LOGGER in full_config: _level = "NONE" @@ -448,7 +448,6 @@ def _final_validate(config): raise cv.Invalid( f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/switch/__init__.py b/esphome/components/haier/switch/__init__.py index acff0cf265..99ffcb37af 100644 --- a/esphome/components/haier/switch/__init__.py +++ b/esphome/components/haier/switch/__init__.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() for switch_type in [CONF_BEEPER, CONF_QUIET_MODE]: # Check switches that are only supported for HonClimate @@ -72,7 +72,6 @@ def _final_validate(config): raise cv.Invalid( f"{switch_type} switch is only supported for hon climate" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d18ae0d9af..8eafe1d9d6 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -217,8 +217,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("havells_solar", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("havells_solar", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index a404fbbade..24b8197073 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -315,7 +315,7 @@ def _validate_config(config: ConfigType) -> ConfigType: return config -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate requirements when using HUB75 display.""" # Local imports to avoid circular dependencies from esphome.components.esp32 import get_esp32_variant @@ -381,8 +381,6 @@ def _final_validate(config: ConfigType) -> ConfigType: if errs: raise cv.MultipleInvalid(errs) - return config - FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 4266f5b78b..3e2a6db1bc 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -22,7 +22,7 @@ CONFIG_SCHEMA = ( ) -def validate_logger(config): +def validate_logger(config) -> None: logger_conf = fv.full_config.get()[CONF_LOGGER] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") @@ -33,7 +33,6 @@ def validate_logger(config): raise cv.Invalid( "improv_serial does not support the selected logger hardware_uart" ) - return config FINAL_VALIDATE_SCHEMA = validate_logger diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 47c8c898e5..a0c0d5dc18 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -146,13 +146,12 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_cpu_frequency(config): +def _validate_cpu_frequency(config) -> None: esp32_config = fv.full_config.get()[PLATFORM_ESP32] if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": raise cv.Invalid( "Inkplate requires 240MHz CPU frequency (set in esp32 component)" ) - return config FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 51c5fc6118..bdc68b5257 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -336,7 +336,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config): +def _final_validate(config) -> None: # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( config @@ -351,7 +351,6 @@ def _final_validate(config): config[CONF_UPDATE_INTERVAL] = update_interval("never") else: config[CONF_SHOW_TEST_CARD] = True - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index c11ede9db6..2b53e70756 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -89,8 +89,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("kuntze", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("kuntze", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index c327c331c6..508d5c2bc6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -84,7 +84,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] @@ -108,8 +108,6 @@ def final_validate(config: ConfigType) -> ConfigType: path=[CONF_WAKE], ) - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 7e0be66c64..452e38d6e3 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -105,9 +105,9 @@ CONFIG_SCHEMA = cv.Schema( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: if config.get(CONF_AREA_CONFIG) is None: - return config + return full_config = fv.full_config.get() hub_id = config[CONF_LD6002B_ID] @@ -132,8 +132,6 @@ def final_validate(config: ConfigType) -> ConfigType: path=[CONF_AREA_CONFIG], ) - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7c4d7ed431..b5b3d7c905 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -165,7 +165,7 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, @@ -173,7 +173,7 @@ def _final_validate(config: ConfigType) -> ConfigType: """ data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: - return config + return # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. @@ -217,8 +217,6 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 1642f6149a..99d4988c90 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -34,7 +34,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config): +def _validate_nonvolatile(config) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -49,7 +49,7 @@ def _validate_nonvolatile(config): f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the " f"volatile channels A-D; channels E-H are the nonvolatile registers themselves" ) - return config + return config.setdefault(CONF_NONVOLATILE, True) if config[CONF_NONVOLATILE]: @@ -62,7 +62,6 @@ def _validate_nonvolatile(config): raise cv.Invalid( f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'" ) - return config CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2d4f6085e5..24bce0cc3c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -62,7 +62,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config -def _require_network_interface(config: ConfigType) -> ConfigType: +def _require_network_interface(config: ConfigType) -> None: """Require a network interface for mDNS on Arduino/LEAmDNS platforms. On ESP8266 and RP2040 the C++ implementation needs at least one IP state @@ -71,7 +71,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType: that never initializes. """ if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): - return config + return full_config = fv.full_config.get() has_wifi = "wifi" in full_config has_ethernet = CORE.is_rp2 and "ethernet" in full_config @@ -81,7 +81,6 @@ def _require_network_interface(config: ConfigType) -> ConfigType: "mdns on this platform requires a network interface — " f"add a {options} component to your configuration." ) - return config CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index e5bb3d413d..8c125a9606 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -175,7 +175,7 @@ def _config_schema(config): return config -def _final_validate(config): +def _final_validate(config) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -183,7 +183,6 @@ def _final_validate(config): if not requires_buffer(config) and LVGL_DOMAIN not in global_config: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - return config CONFIG_SCHEMA = _config_schema diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index ebe930d37a..897088a257 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -248,7 +248,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _final_validate(config): +def _final_validate(config) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -260,7 +260,6 @@ def _final_validate(config): config = spi.final_validate_device_schema( "mipi_rgb", require_miso=False, require_mosi=True )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 64475d0e32..05a29b3665 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -143,11 +143,11 @@ def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: # Legacy climate-owned hub compatibility. Remove in 2027.2.0. -def _legacy_final_validate(config: ConfigType) -> ConfigType: +def _legacy_final_validate(config: ConfigType) -> None: if CONF_MITSUBISHI_CN105_ID in config: - return config + return - return uart.final_validate_device_schema( + uart.final_validate_device_schema( DOMAIN, require_rx=True, require_tx=True, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 1ce1e38d16..f3cd28d138 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -135,10 +135,8 @@ def validate_modbus_register(config): return config -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("modbus_controller", role="client")( - config - ) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("modbus_controller", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 16b956d7b5..249454b6b0 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -144,8 +144,8 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("modbus_server", role="server")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("modbus_server", role="server")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 09bbf91c99..3291ff2c59 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -44,10 +44,10 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config): +def _final_validate(config) -> None: if config[CONF_TYPE] != CONF_STATUS: # Only run this validation if a status sensor is being configured - return config + return full_config = fv.full_config.get() transport_path = full_config.get_path_for_id(config[CONF_TRANSPORT_ID])[:-1] transport_config = full_config.get_config_for_path(transport_path) @@ -56,7 +56,7 @@ def _final_validate(config): for p in transport_config[CONF_PROVIDERS] if p[CONF_NAME] == config[CONF_PROVIDER] ): - return config + return raise cv.Invalid( "Status sensor requires ping-pong to be enabled and the nominated provider to use encryption." ) diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py index 36fa69357a..9462bbb3b7 100644 --- a/esphome/components/provisioning/__init__.py +++ b/esphome/components/provisioning/__init__.py @@ -67,7 +67,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate the provisioning setup once every component has been processed. Sources register during their own config validation, so by final validation @@ -89,7 +89,6 @@ def _final_validate(config: ConfigType) -> ConfigType: "hardcoding them makes the window pointless.", ", ".join(sorted(data.hardcoded_credentials)), ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 4e228f6aa3..5bb734cb2d 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -98,8 +98,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("pzemac", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("pzemac", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 40cfe7b08a..b2c7c3a29d 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -80,8 +80,8 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("pzemdc", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("pzemdc", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index 2b2dc56433..18311416c3 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: # Validate every configured output speaker can accept the router's format. # Switching to an output that can't reproduce the format the producer is # already sending would otherwise fail silently at runtime. @@ -76,7 +76,6 @@ def _final_validate(config: ConfigType) -> ConfigType: channels=config[CONF_NUM_CHANNELS], sample_rate=config[CONF_SAMPLE_RATE], )(proxy) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 332ea73a61..d2a08e9fc0 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -71,10 +71,9 @@ def validate_connection_slots() -> None: ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: _validate_board(config) validate_connection_slots() - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 46f5025080..125240e891 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -148,8 +148,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("sdm_meter", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 2d7b6b07e5..59ee6667a1 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: # In the default mode setup() writes config commands, so tx is required; # rx_only mode never writes, so tx is optional. uart.final_validate_device_schema( @@ -75,7 +75,6 @@ def _final_validate(config): parity="NONE", stop_bits=1, )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index ef4929c375..120b997605 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -164,8 +164,8 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("selec_meter", role="client")(config) +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("selec_meter", role="client")(config) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 9e1ad3afc4..4c6f4db85b 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if not any(name in full_config for name in _USB_CLASS_COMPONENTS): raise cv.Invalid( @@ -75,7 +75,6 @@ def _final_validate(config): "USB_SERIAL_JTAG on variants that support it " "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index c1887cc3fc..b2c0ea14ad 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -193,7 +193,7 @@ def _validate_no_sorting_component( ) -def _final_validate_sorting(config: ConfigType) -> ConfigType: +def _final_validate_sorting(config: ConfigType) -> None: if (webserver_version := config.get(CONF_VERSION)) != 3: _validate_no_sorting_component( CONF_SORTING_WEIGHT, webserver_version, fv.full_config.get() @@ -201,7 +201,6 @@ def _final_validate_sorting(config: ConfigType) -> ConfigType: _validate_no_sorting_component( CONF_SORTING_GROUP_ID, webserver_version, fv.full_config.get() ) - return config FINAL_VALIDATE_SCHEMA = _final_validate_sorting diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py index 54c04473e3..b7ee27f63c 100644 --- a/esphome/components/zephyr_pwm/output.py +++ b/esphome/components/zephyr_pwm/output.py @@ -102,9 +102,8 @@ def _allocate_blocks() -> None: _get_data().pwm_blocks = pwm_blocks -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: _allocate_blocks() - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index d88f9f7041..14b8474045 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -11,7 +11,7 @@ zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) -def final_validate(config): +def final_validate(config) -> None: full_config = fv.full_config.get() if (wifi_conf := full_config.get(CONF_WIFI)) and ( wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" @@ -20,8 +20,6 @@ def final_validate(config): f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy" ) - return config - CONFIG_SCHEMA = ( cv.Schema( diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py index cec81e4e83..5cc3f928cc 100644 --- a/tests/component_tests/esp32_hosted/test_init.py +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -19,7 +19,7 @@ def test_final_validate_accepts_supported_idf( PlatformFramework.ESP32_IDF, platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, ) - assert _final_validate({}) == {} + _final_validate({}) @pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 78462463b1..f52c477c85 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -371,27 +371,28 @@ def test_migrate_returns_none_for_invalid_legacy_shapes( def test_validate_image_final_defaults_to_little_endian() -> None: - out = validate_image_final({CONF_FILE: "x.png"}) - assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + config = {CONF_FILE: "x.png"} + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" def test_validate_image_final_keeps_little_endian( caplog: pytest.LogCaptureFixture, ) -> None: + config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} with caplog.at_level(logging.WARNING): - out = validate_image_final( - {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} - ) - assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" assert "big-endian" not in caplog.text def test_validate_image_final_warns_on_big_endian( caplog: pytest.LogCaptureFixture, ) -> None: + config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"} with caplog.at_level(logging.WARNING): - out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) - assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + validate_image_final(config) + assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN" assert "big-endian" in caplog.text diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py index 07f5065241..d3a3771bbc 100644 --- a/tests/component_tests/provisioning/test_provisioning.py +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -37,7 +37,7 @@ def test_provisioning_accepts_a_registered_source( set_core_config(PlatformFramework.ESP32_IDF) register_source("network") # Should not raise. - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) def test_provisioning_warns_on_hardcoded_credentials( @@ -49,7 +49,7 @@ def test_provisioning_warns_on_hardcoded_credentials( register_source("network") report_hardcoded_credentials("wifi") with caplog.at_level(logging.WARNING): - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) assert "wifi" in caplog.text assert "credentials" in caplog.text @@ -62,7 +62,7 @@ def test_provisioning_no_warning_without_hardcoded_credentials( set_core_config(PlatformFramework.ESP32_IDF) register_source("network") with caplog.at_level(logging.WARNING): - assert FINAL_VALIDATE_SCHEMA({}) == {} + FINAL_VALIDATE_SCHEMA({}) assert "credentials" not in caplog.text From 7362c01c6744e0be6eae307a31486d59f8b50f49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 16:14:06 -0500 Subject: [PATCH 229/597] [core] Replace base64 lookup tables with arithmetic mapping (#18454) --- esphome/core/alloc_helpers.cpp | 16 ++++-- esphome/core/helpers.cpp | 24 ++++----- tests/components/core/test_helpers.cpp | 67 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index d9cfad70b9..f6130b7b78 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -88,9 +88,17 @@ std::string str_sprintf(const char *fmt, ...) { // --- Base64 helpers --- -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; +// Map a 6-bit value (0-63) to its base64 character arithmetically. +// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). +static inline char base64_char(uint8_t index) { + if (index < 26) + return 'A' + index; + if (index < 52) + return 'a' + (index - 26); + if (index < 62) + return '0' + (index - 52); + return index == 62 ? '+' : '/'; +} // Encode 3 input bytes to 4 base64 characters, append 'count' to ret. static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) { @@ -101,7 +109,7 @@ static inline void base64_encode_triple(const char *char_array_3, int count, std char_array_4[3] = char_array_3[2] & 0x3f; for (int j = 0; j < count; j++) - ret += BASE64_CHARS[static_cast(char_array_4[j])]; + ret += base64_char(static_cast(char_array_4[j])); } std::string base64_encode(const std::vector &buf) { return base64_encode(buf.data(), buf.size()); } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 8c4442f1b2..bd08d3b63e 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -579,13 +579,8 @@ int8_t step_to_accuracy_decimals(float step) { return str.length() - dot_pos - 1; } -// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes) -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; - -// Helper function to find the index of a base64/base64url character in the lookup table. -// Returns the character's position (0-63) if found, or 0 if not found. +// Map a base64/base64url character to its 6-bit value (0-63) arithmetically. +// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). // Supports both standard base64 (+/) and base64url (-_) alphabets. // NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters. // This is safe because is_base64() is ALWAYS checked before calling this function, @@ -593,13 +588,18 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" // stops processing at the first invalid character due to the is_base64() check in its // while loop condition, making this edge case harmless in practice. static inline uint8_t base64_find_char(char c) { - // Handle base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63) - if (c == '-') + if (c >= 'A' && c <= 'Z') + return c - 'A'; + if (c >= 'a' && c <= 'z') + return c - 'a' + 26; + if (c >= '0' && c <= '9') + return c - '0' + 52; + // base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63) + if (c == '+' || c == '-') return 62; - if (c == '_') + if (c == '/' || c == '_') return 63; - const char *pos = strchr(BASE64_CHARS, c); - return pos ? (pos - BASE64_CHARS) : 0; + return 0; } // Check if character is valid base64 or base64url diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 5fb77ef753..3767b24d86 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -1,6 +1,7 @@ #include #include +#include "esphome/core/alloc_helpers.h" #include "esphome/core/helpers.h" namespace esphome::core::testing { @@ -213,4 +214,70 @@ TEST(BufAppendSepStr, Truncation) { EXPECT_EQ(end - buf, 7); } +// --- base64 encode/decode --- + +static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order +TEST(Base64, EncodeProducesCanonicalAlphabet) { + uint8_t bytes[48]; + size_t n = 0; + for (uint8_t i = 0; i < 64; i += 4) { + bytes[n++] = (i << 2) | ((i + 1) >> 4); + bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2); + bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3); + } + std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(encoded, BASE64_ALPHABET); +} + +// Decode the alphabet then re-encode: locks the encode and decode mappings together +TEST(Base64, DecodeCanonicalAlphabetRoundTrip) { + uint8_t buf[48]; + size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf)); + EXPECT_EQ(len, 48u); + std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(reencoded, BASE64_ALPHABET); +} + +TEST(Base64, DecodeBase64UrlMatchesStandard) { + std::string url = BASE64_ALPHABET; + for (char &c : url) { + if (c == '+') + c = '-'; + if (c == '/') + c = '_'; + } + uint8_t standard[48], urlsafe[48]; + size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard)); + size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe)); + EXPECT_EQ(len_standard, len_url); + EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0); +} + +// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2) +TEST(Base64, Rfc4648Vectors) { + const struct { + const char *plain; + const char *encoded; + } vectors[] = { + {"", ""}, + {"f", "Zg=="}, + {"fo", "Zm8="}, + {"foo", "Zm9v"}, + {"foob", "Zm9vYg=="}, + {"fooba", "Zm9vYmE="}, + {"foobar", "Zm9vYmFy"}, + }; + for (const auto &v : vectors) { + const auto *plain = reinterpret_cast(v.plain); + std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test + EXPECT_EQ(encoded, v.encoded); + uint8_t buf[8]; + size_t len = base64_decode(reinterpret_cast(v.encoded), strlen(v.encoded), buf, sizeof(buf)); + EXPECT_EQ(len, strlen(v.plain)); + EXPECT_EQ(memcmp(buf, v.plain, len), 0); + } +} + } // namespace esphome::core::testing From 96e26c6a5f4d7eed5cae1a46eaa1d23b348b5c36 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:23 -0500 Subject: [PATCH 230/597] Bump bundled esphome-device-builder to 1.11.1 (#18475) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23b2f690..b78d183e02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 RUN \ platformio settings set enable_telemetry No \ From 7be4566b411d96f7a103879c80f38c9248ec97a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:33:13 -0500 Subject: [PATCH 231/597] Bump platformdirs from 4.11.2 to 4.11.3 (#18468) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4b1708637d..a986646230 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.2 # native esp-idf toolchain global cache dir +platformdirs==4.11.3 # native esp-idf toolchain global cache dir filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this From a347a2e8793243dbaa5ffcc4b07fe3481abbe252 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 18:58:11 -0500 Subject: [PATCH 232/597] [api] Bump noise-c to 0.1.19 (#18473) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5ca9484336..cdc0d97c49 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.18") + cg.add_library("esphome/noise-c", "0.1.19") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 2c22523be5..39600d622a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.18 ; used by api + esphome/noise-c@0.1.19 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 15a626bcf34cc6905bb5c972dcf74475a86af691 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:38 -0500 Subject: [PATCH 233/597] Bump bundled esphome-device-builder to 1.11.2 (#18477) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b78d183e02..4a8daeaaf6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 RUN \ platformio settings set enable_telemetry No \ From 4416aacebb4b991a3c8916efffa21a62fa42cf70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:09:29 -0500 Subject: [PATCH 234/597] [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b80a394eec..8d00dbede2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -45,6 +45,11 @@ namespace esphome::socket { static const char *const TAG = "socket"; +#ifdef USE_ESP8266 +// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. +static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +#endif + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { +#ifdef USE_ESP8266 + // Would block: yield to SYS so queued WiFi RX reaches lwip and this read + // may succeed. Without this, inbound segments can sit unprocessed for + // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + if (this->waiting_for_data_()) { + optimistic_yield(ESP8266_YIELD_INTERVAL_US); + } +#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // No ESP8266 SYS yield here: only read() needs it today. If a consumer + // switches to scatter-gather reads, mirror the yield from read(). // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() { } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); - if (err == ERR_ABRT) { - // sometimes lwip returns ERR_ABRT for no apparent reason - // the connection works fine afterwards, and back with ESPAsyncTCP we - // indirectly also ignored this error - // FIXME: figure out where this is returned and what it means in this context - LWIP_LOG(" -> err ERR_ABRT"); - return 0; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = ECONNRESET; - return -1; + // ERR_ABRT: sometimes lwip returns it for no apparent reason; the + // connection works fine afterwards, and back with ESPAsyncTCP we + // indirectly also ignored this error, so treat it as success for + // flush purposes too. + // FIXME: figure out where this is returned and what it means in this context + if (err != ERR_ABRT) { + errno = ECONNRESET; + return -1; + } } +#ifdef USE_ESP8266 + // Flushed: yield to SYS so the queued segments reach the WiFi driver + // instead of waiting seconds for an unrelated SYS slot. Callers only get + // here after a successful tcp_write, so idle paths never yield. + optimistic_yield(ESP8266_YIELD_INTERVAL_US); +#endif return 0; } From 463e3833dae23329ad484c1a549dab13c2de7541 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:48 +1200 Subject: [PATCH 235/597] [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) --- .../beken_spi_led_strip/led_strip.cpp | 75 ++------- .../beken_spi_led_strip/led_strip.h | 23 +-- .../components/beken_spi_led_strip/light.py | 41 +++-- esphome/components/const/__init__.py | 2 + .../esp32_rmt_led_strip/led_strip.cpp | 86 ++--------- .../esp32_rmt_led_strip/led_strip.h | 29 +--- .../components/esp32_rmt_led_strip/light.py | 65 ++------ esphome/components/light/__init__.py | 106 +++++++++++++ esphome/components/light/channel_colors.h | 41 +++++ esphome/components/light/types.py | 3 + .../rp2040_pio_led_strip/led_strip.cpp | 59 ++----- .../rp2040_pio_led_strip/led_strip.h | 42 +---- .../components/rp2040_pio_led_strip/light.py | 33 ++-- .../common-ard-esp32_rmt_led_strip.yaml | 2 +- .../common-idf-esp32_rmt_led_strip.yaml | 2 +- .../beken_spi_led_strip/test.bk72xx-ard.yaml | 2 +- .../validate-legacy.bk72xx-ard.yaml | 10 ++ tests/components/e131/common-ard.yaml | 2 +- tests/components/e131/common-idf.yaml | 2 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- .../esp32_rmt_led_strip/common.yaml | 4 +- .../test.esp32-s3-idf.yaml | 4 +- .../validate-legacy.esp32-idf.yaml | 23 +++ tests/components/partition/common-ard.yaml | 2 +- tests/components/partition/common-idf.yaml | 2 +- .../rp2040_pio_led_strip/common.yaml | 4 +- .../validate-legacy.rp2040-ard.yaml | 18 +++ tests/components/wled/test.esp32-ard.yaml | 2 +- .../components/light/test_channel_colors.py | 144 ++++++++++++++++++ .../components/test_esp32_rmt_led_strip.py | 57 ------- 30 files changed, 454 insertions(+), 433 deletions(-) create mode 100644 esphome/components/light/channel_colors.h create mode 100644 tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml create mode 100644 tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml create mode 100644 tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml create mode 100644 tests/unit_tests/components/light/test_channel_colors.py delete mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 909634e266..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..2be5842818 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 3ba89d2838..10710c8d29 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" +CONF_CHANNEL_COLORS = "channel_colors" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_IS_WRGB = "is_wrgb" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 95391ef100..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - - return {this->buf_ + (index * multiplier) + r + (white <= r), - this->buf_ + (index * multiplier) + g + (white <= g), - this->buf_ + (index * multiplier) + b + (white <= b), - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } - if (this->is_rgbw_ || this->is_wrgb_) { - char rgbw_order[5]; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - uint8_t rgb_index = 0; - for (uint8_t i = 0; i < 4; i++) { - rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; - } - rgbw_order[4] = '\0'; - ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); - } else { - ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 3e31309bff..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,15 +16,6 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } - void set_rgbw_order(uint8_t white_index) { - this->is_rgbw_ = true; - this->is_wrgb_ = false; - this->white_index_ = white_index; - } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; - // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. - uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2722a9b656..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +21,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -62,8 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" -CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" -def _validate_rgbw_order(value: str) -> str: - value = cv.string(value).upper() - if len(value) != 4 or set(value) != set("RGBW"): - raise cv.Invalid("RGBW order must be a permutation of RGBW") - return value - - -def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: - return rgbw_order.replace("W", ""), rgbw_order.index("W") - - -def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: - if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): - raise cv.Invalid( - f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " - f"'{CONF_IS_WRGB}'" - ) - return config - - CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), - cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), - cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), - _validate_rgbw_order_exclusivity, + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -198,14 +164,9 @@ async def to_code(config): ) ) - if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: - rgb_order, white_index = _split_rgbw_order(rgbw_order) - cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) - cg.add(var.set_rgbw_order(white_index)) - else: - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index b5b3d7c905..175f5b43cf 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +26,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +36,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -61,6 +66,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +77,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,6 +173,104 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d1..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index cf7041931e..1f4bea9ecd 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->buf_, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ ? 4 : 3; - return {this->buf_ + (index * multiplier) + r, - this->buf_ + (index * multiplier) + g, - this->buf_ + (index * multiplier) + b, - this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } void RP2040PIOLEDStripLightOutput::dump_config() { + char channel_colors[5]; ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:\n" " Pin: GPIO%d\n" " Number of LEDs: %d\n" - " RGBW: %s\n" - " RGB Order: %s\n" + " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_), - this->max_refresh_rate_); + this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index c499f0a7ca..b2162f641d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include @@ -18,15 +19,6 @@ namespace esphome::rp2040_pio_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - enum Chipset : uint8_t { CHIPSET_WS2812, CHIPSET_WS2812B, @@ -36,25 +28,6 @@ enum Chipset : uint8_t { CHIPSET_CUSTOM = 0xFF, }; -inline const char *rgb_order_to_string(RGBOrder order) { - switch (order) { - case ORDER_RGB: - return "RGB"; - case ORDER_RBG: - return "RBG"; - case ORDER_GRB: - return "GRB"; - case ORDER_GBR: - return "GBR"; - case ORDER_BGR: - return "BGR"; - case ORDER_BRG: - return "BRG"; - default: - return "UNKNOWN"; - } -} - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); + this->channel_colors_.has_white() + ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void clear_effect_data() override { for (int i = 0; i < this->size(); i++) { this->effect_data_[i] = 0; @@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } static void dma_write_complete_handler(); @@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint32_t num_leds_; - bool is_rgbw_; pio_hw_t *pio_; uint sm_; uint dma_chan_; dma_channel_config dma_config_; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; uint32_t last_refresh_{0}; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index b3f816102a..9f7479edd0 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import light, rp2 +from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType from esphome.util import _LOGGER @@ -37,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): +def generate_assembly_code(id, t0h, t0l, t1h, t1l): """ Generate assembly code with the given timing values. """ @@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") - Chipset = rp2040_pio_led_strip_ns.enum("Chipset") CHIPSETS = { @@ -159,15 +159,6 @@ class LEDStripTimings: T1L: int -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - CHIPSET_TIMINGS = { "WS2812": LEDStripTimings(20, 40, 46, 34), "WS2812B": LEDStripTimings(23, 49, 46, 26), @@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="rp2040_pio_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) id = config[CONF_ID].id await light.register_light(var, config) @@ -234,8 +230,9 @@ async def to_code(config): cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_pio(config[CONF_PIO])) cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) @@ -255,7 +252,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], CHIPSET_TIMINGS[chipset].T0H, CHIPSET_TIMINGS[chipset].T0L, CHIPSET_TIMINGS[chipset].T1H, @@ -270,7 +266,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], time_to_cycles(config[CONF_BIT0_HIGH]), time_to_cycles(config[CONF_BIT0_LOW]), time_to_cycles(config[CONF_BIT1_HIGH]), diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml index 15409caeaf..2bb831848c 100644 --- a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml +++ b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml @@ -1,6 +1,6 @@ light: - platform: beken_spi_led_strip - rgb_order: GRB + channel_colors: GRB pin: P16 num_leds: 30 chipset: ws2812 diff --git a/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml new file mode 100644 index 0000000000..3ca78398c3 --- /dev/null +++ b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml @@ -0,0 +1,10 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only, and only one strip because P16 is the sole supported pin. +light: + - platform: beken_spi_led_strip + name: Legacy RGBW + pin: P16 + num_leds: 30 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/e131/common-ard.yaml b/tests/components/e131/common-ard.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-ard.yaml +++ b/tests/components/e131/common-ard.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/common-idf.yaml b/tests/components/e131/common-idf.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-idf.yaml +++ b/tests/components/e131/common-idf.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..89255e2d87 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -6,7 +6,7 @@ light: pin: 2 pio: 0 num_leds: 256 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 effects: - e131: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 701e513ebd..7f52d32229 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -3,13 +3,13 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgbw_order: RWGB + channel_colors: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 6bf0639a52..132966eddf 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -8,14 +8,14 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + channel_colors: RGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml new file mode 100644 index 0000000000..6dd1bcdad3 --- /dev/null +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -0,0 +1,23 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: esp32_rmt_led_strip + id: legacy_rgb + pin: GPIO13 + num_leds: 60 + chipset: ws2812 + rgb_order: GRB # -> GRB + - platform: esp32_rmt_led_strip + id: legacy_rgbw + pin: GPIO14 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW + - platform: esp32_rmt_led_strip + id: legacy_wrgb + pin: GPIO15 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_wrgb: true # -> WGRB diff --git a/tests/components/partition/common-ard.yaml b/tests/components/partition/common-ard.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-ard.yaml +++ b/tests/components/partition/common-ard.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/common-idf.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/common-idf.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..1cb5fe0737 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -4,14 +4,14 @@ light: pin: 4 num_leds: 60 pio: 0 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 - platform: rp2040_pio_led_strip id: led_strip_custom_timings pin: 5 num_leds: 60 pio: 1 - rgb_order: GRB + channel_colors: GRB bit0_high: .1us bit0_low: 1.2us bit1_high: .69us diff --git a/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml new file mode 100644 index 0000000000..2ab124393b --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: rp2040_pio_led_strip + id: legacy_rgb + pin: 4 + num_leds: 60 + pio: 0 + chipset: WS2812 + rgb_order: GRB # -> GRB + - platform: rp2040_pio_led_strip + id: legacy_rgbw + pin: 5 + num_leds: 60 + pio: 1 + chipset: SK6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/wled/test.esp32-ard.yaml b/tests/components/wled/test.esp32-ard.yaml index 156b31181e..ecab767812 100644 --- a/tests/components/wled/test.esp32-ard.yaml +++ b/tests/components/wled/test.esp32-ard.yaml @@ -9,7 +9,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: 2 effects: diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py new file mode 100644 index 0000000000..0c129a8bb2 --- /dev/null +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -0,0 +1,144 @@ +"""Tests for the shared addressable-strip channel order helpers.""" + +import logging + +import pytest + +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.components.light import ( + channel_colors_struct, + migrate_channel_colors, + validate_channel_colors, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER +from esphome.types import ConfigType + +NO_WHITE = "light::ChannelColors::NO_WHITE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", "RGB"), + ("grb", "GRB"), + ("BRG", "BRG"), + ("rgbw", "RGBW"), + ("WRGB", "WRGB"), + ("GWRB", "GWRB"), + ], +) +def test_validate_channel_colors(value: str, expected: str) -> None: + assert validate_channel_colors(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "RG", # missing a channel + "RGBB", # duplicate channel + "RRGB", # duplicate channel, correct length + "RGBWW", # two white channels + "RGBX", # unknown channel + "RGBWX", # unknown channel, correct length + "", + ], +) +def test_validate_channel_colors_rejects_invalid(value: str) -> None: + with pytest.raises(cv.Invalid, match="is not a valid channel order"): + validate_channel_colors(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", (0, 1, 2, NO_WHITE)), + ("GRB", (1, 0, 2, NO_WHITE)), + ("BRG", (1, 2, 0, NO_WHITE)), + ("RGBW", (0, 1, 2, 3)), + ("GRBW", (1, 0, 2, 3)), + ("WRGB", (1, 2, 3, 0)), + ("GWRB", (2, 0, 3, 1)), + ], +) +def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: + struct = channel_colors_struct(value) + assert str(struct.base) == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.args.values()) == tuple( + str(field) for field in expected + ) + + +def _migrate(config: ConfigType) -> ConfigType: + return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config) + + +def test_migrate_passes_through_channel_colors() -> None: + config = {CONF_CHANNEL_COLORS: "GRBW"} + assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"} + + +@pytest.mark.parametrize( + ("deprecated", "expected", "named"), + [ + ({}, "GRB", "'rgb_order' is"), + ( + {CONF_IS_RGBW: False, CONF_IS_WRGB: False}, + "GRB", + "'rgb_order', 'is_rgbw' and 'is_wrgb' are", + ), + ({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"), + ({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"), + ], +) +def test_migrate_folds_deprecated_keys( + deprecated: ConfigType, + expected: str, + named: str, + caplog: pytest.LogCaptureFixture, +) -> None: + config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated} + with caplog.at_level(logging.WARNING): + result = _migrate(config) + + assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1} + assert f"[test_strip] {named} deprecated" in caplog.text + assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text + assert "2027.3.0" in caplog.text + + +def test_migrate_does_not_mutate_input() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + _migrate(config) + assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + + +@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB]) +def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None: + config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"} + with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"): + _migrate(config) + + +def test_migrate_reports_every_conflicting_key() -> None: + config = { + CONF_CHANNEL_COLORS: "GRBW", + CONF_RGB_ORDER: "GRB", + CONF_IS_RGBW: True, + CONF_IS_WRGB: False, + } + with pytest.raises( + cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'" + ): + _migrate(config) + + +def test_migrate_requires_channel_colors() -> None: + with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"): + _migrate({"num_leds": 1}) + + +def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True} + with pytest.raises(cv.Invalid, match="cannot both be enabled"): + _migrate(config) diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py deleted file mode 100644 index e2cb513e3b..0000000000 --- a/tests/unit_tests/components/test_esp32_rmt_led_strip.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from esphome.components.esp32_rmt_led_strip.light import ( - CONF_IS_WRGB, - CONF_RGBW_ORDER, - _split_rgbw_order, - _validate_rgbw_order, - _validate_rgbw_order_exclusivity, -) -import esphome.config_validation as cv -from esphome.const import CONF_IS_RGBW - - -def test_validate_rgbw_order() -> None: - assert _validate_rgbw_order("rwgb") == "RWGB" - - -@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) -def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: - with pytest.raises(cv.Invalid, match="permutation of RGBW"): - _validate_rgbw_order(rgbw_order) - - -@pytest.mark.parametrize( - ("rgbw_order", "expected"), - [ - ("WRGB", ("RGB", 0)), - ("RWGB", ("RGB", 1)), - ("GWRB", ("GRB", 1)), - ("RGBW", ("RGB", 3)), - ], -) -def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: - assert _split_rgbw_order(rgbw_order) == expected - - -@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: - with pytest.raises(cv.Invalid, match="cannot be used with"): - _validate_rgbw_order_exclusivity( - { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: conflict == CONF_IS_RGBW, - CONF_IS_WRGB: conflict == CONF_IS_WRGB, - } - ) - - -@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: - config = { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: False, - CONF_IS_WRGB: False, - } - config[legacy_option] = False - assert _validate_rgbw_order_exclusivity(config) is config From fffa902a1a78ac0daa6c759de0f20611e4846fbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH 236/597] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + ) From 096e71bd678ff5707ddbd013fe59c012e3abc8f9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:14 -0500 Subject: [PATCH 237/597] Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 85a0f55263..683008400a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.1 +aioesphomeapi==45.10.2 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 3a403c40d5f7d02dcf6bfb8eca6d614471fa3b91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Aug 2026 19:57:12 -0700 Subject: [PATCH 238/597] [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) --- esphome/components/ld2420/ld2420.cpp | 2 -- esphome/components/ld2420/ld2420.h | 1 - 2 files changed, 3 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index f71bec7e5f..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 977ee2eccc..e13d0271e1 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); void handle_cmd_error(uint16_t error); From b9041566eaee70079271526dc3104360a78d067c Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:00 -0500 Subject: [PATCH 239/597] Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 683008400a..080a437147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.2 +aioesphomeapi==45.10.3 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 014cc199021325153f0572c17f85386760e5ae09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 12:00:39 -0700 Subject: [PATCH 240/597] [api] Bump noise-c to 0.1.18 (#18451) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 8ec94df1db..5ca9484336 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") + cg.add_library("esphome/noise-c", "0.1.18") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index bf3b0685f8..2c22523be5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.18 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.11 ; used by api + esphome/noise-c@0.1.18 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 4ce6d59484be6fb55b1bf7cd4d0bdea4785ee1d1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:23 -0500 Subject: [PATCH 241/597] Bump bundled esphome-device-builder to 1.11.1 (#18475) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2f23b2f690..b78d183e02 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 RUN \ platformio settings set enable_telemetry No \ From 1fd63372545525bfa8cc48e781fb96101b37e3f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 18:58:11 -0500 Subject: [PATCH 242/597] [api] Bump noise-c to 0.1.19 (#18473) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5ca9484336..cdc0d97c49 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.18") + cg.add_library("esphome/noise-c", "0.1.19") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 2c22523be5..39600d622a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.18 ; api + esphome/noise-c@0.1.19 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.18 ; used by api + esphome/noise-c@0.1.19 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 4dea147386d4d309e84ec3eee96e6169a224cf40 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:38 -0500 Subject: [PATCH 243/597] Bump bundled esphome-device-builder to 1.11.2 (#18477) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b78d183e02..4a8daeaaf6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 RUN \ platformio settings set enable_telemetry No \ From 482869fbbe04a8ff28746f066e90ee7d57bbe81e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:09:29 -0500 Subject: [PATCH 244/597] [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 4fcec553fa..098056d499 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -45,6 +45,11 @@ namespace esphome::socket { static const char *const TAG = "socket.lwip"; +#ifdef USE_ESP8266 +// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. +static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; +#endif + // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { } ssize_t LWIPRawImpl::read(void *buf, size_t len) { +#ifdef USE_ESP8266 + // Would block: yield to SYS so queued WiFi RX reaches lwip and this read + // may succeed. Without this, inbound segments can sit unprocessed for + // seconds while the main loop polls (CONT/SYS are cooperative on ESP8266). + if (this->waiting_for_data_()) { + optimistic_yield(ESP8266_YIELD_INTERVAL_US); + } +#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { } ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // No ESP8266 SYS yield here: only read() needs it today. If a consumer + // switches to scatter-gather reads, mirror the yield from read(). // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() { } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); - if (err == ERR_ABRT) { - // sometimes lwip returns ERR_ABRT for no apparent reason - // the connection works fine afterwards, and back with ESPAsyncTCP we - // indirectly also ignored this error - // FIXME: figure out where this is returned and what it means in this context - LWIP_LOG(" -> err ERR_ABRT"); - return 0; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = ECONNRESET; - return -1; + // ERR_ABRT: sometimes lwip returns it for no apparent reason; the + // connection works fine afterwards, and back with ESPAsyncTCP we + // indirectly also ignored this error, so treat it as success for + // flush purposes too. + // FIXME: figure out where this is returned and what it means in this context + if (err != ERR_ABRT) { + errno = ECONNRESET; + return -1; + } } +#ifdef USE_ESP8266 + // Flushed: yield to SYS so the queued segments reach the WiFi driver + // instead of waiting seconds for an unrelated SYS slot. Callers only get + // here after a successful tcp_write, so idle paths never yield. + optimistic_yield(ESP8266_YIELD_INTERVAL_US); +#endif return 0; } From 6a247dfe912477e516f8da6f13e4ab002544a44e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:48 +1200 Subject: [PATCH 245/597] [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) --- .../beken_spi_led_strip/led_strip.cpp | 75 ++------- .../beken_spi_led_strip/led_strip.h | 23 +-- .../components/beken_spi_led_strip/light.py | 41 +++-- esphome/components/const/__init__.py | 2 + .../esp32_rmt_led_strip/led_strip.cpp | 86 ++--------- .../esp32_rmt_led_strip/led_strip.h | 29 +--- .../components/esp32_rmt_led_strip/light.py | 65 ++------ esphome/components/light/__init__.py | 108 ++++++++++++- esphome/components/light/channel_colors.h | 41 +++++ esphome/components/light/types.py | 3 + .../rp2040_pio_led_strip/led_strip.cpp | 59 ++----- .../rp2040_pio_led_strip/led_strip.h | 42 +---- .../components/rp2040_pio_led_strip/light.py | 33 ++-- .../common-ard-esp32_rmt_led_strip.yaml | 2 +- .../common-idf-esp32_rmt_led_strip.yaml | 2 +- .../beken_spi_led_strip/test.bk72xx-ard.yaml | 2 +- .../validate-legacy.bk72xx-ard.yaml | 10 ++ tests/components/e131/common-ard.yaml | 2 +- tests/components/e131/common-idf.yaml | 2 +- tests/components/e131/test.rp2040-ard.yaml | 2 +- .../esp32_rmt_led_strip/common.yaml | 4 +- .../test.esp32-s3-idf.yaml | 4 +- .../validate-legacy.esp32-idf.yaml | 23 +++ tests/components/partition/common-ard.yaml | 2 +- tests/components/partition/common-idf.yaml | 2 +- .../rp2040_pio_led_strip/common.yaml | 4 +- .../validate-legacy.rp2040-ard.yaml | 18 +++ tests/components/wled/test.esp32-ard.yaml | 2 +- .../components/light/test_channel_colors.py | 144 ++++++++++++++++++ .../components/test_esp32_rmt_led_strip.py | 57 ------- 30 files changed, 455 insertions(+), 434 deletions(-) create mode 100644 esphome/components/light/channel_colors.h create mode 100644 tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml create mode 100644 tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml create mode 100644 tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml create mode 100644 tests/unit_tests/components/light/test_channel_colors.py delete mode 100644 tests/unit_tests/components/test_esp32_rmt_led_strip.py diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 909634e266..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..2be5842818 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 44878274d6..956a5490e3 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" +CONF_CHANNEL_COLORS = "channel_colors" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_IS_WRGB = "is_wrgb" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_NOX_INDEX = "nox_index" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 95391ef100..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - - return {this->buf_ + (index * multiplier) + r + (white <= r), - this->buf_ + (index * multiplier) + g + (white <= g), - this->buf_ + (index * multiplier) + b + (white <= b), - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } - if (this->is_rgbw_ || this->is_wrgb_) { - char rgbw_order[5]; - uint8_t white = this->is_wrgb_ ? 0 : this->white_index_; - uint8_t rgb_index = 0; - for (uint8_t i = 0; i < 4; i++) { - rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++]; - } - rgbw_order[4] = '\0'; - ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order); - } else { - ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order); - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 3e31309bff..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,15 +16,6 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } - void set_rgbw_order(uint8_t white_index) { - this->is_rgbw_ = true; - this->is_wrgb_ = false; - this->white_index_ = white_index; - } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; - // An index after the RGB channels makes offset adjustment a no-op for three-channel strips. - uint8_t white_index_{3}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2722a9b656..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +21,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -62,8 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" -CONF_RGBW_ORDER = "rgbw_order" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high" CONF_RESET_LOW = "reset_low" -def _validate_rgbw_order(value: str) -> str: - value = cv.string(value).upper() - if len(value) != 4 or set(value) != set("RGBW"): - raise cv.Invalid("RGBW order must be a permutation of RGBW") - return value - - -def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]: - return rgbw_order.replace("W", ""), rgbw_order.index("W") - - -def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType: - if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]): - raise cv.Invalid( - f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or " - f"'{CONF_IS_WRGB}'" - ) - return config - - CONFIG_SCHEMA = cv.All( esp32.only_on_variant( unsupported=list(esp32_rmt.VARIANTS_NO_RMT), @@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), - cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order, + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), - cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER), - _validate_rgbw_order_exclusivity, + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -198,14 +164,9 @@ async def to_code(config): ) ) - if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None: - rgb_order, white_index = _split_rgbw_order(rgbw_order) - cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order])) - cg.add(var.set_rgbw_order(white_index)) - else: - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7c4d7ed431..f3a859e38c 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,12 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +26,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +36,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -61,6 +66,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +77,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,7 +173,105 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d1..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index cf7041931e..1f4bea9ecd 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->buf_, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ ? 4 : 3; - return {this->buf_ + (index * multiplier) + r, - this->buf_ + (index * multiplier) + g, - this->buf_ + (index * multiplier) + b, - this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } void RP2040PIOLEDStripLightOutput::dump_config() { + char channel_colors[5]; ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:\n" " Pin: GPIO%d\n" " Number of LEDs: %d\n" - " RGBW: %s\n" - " RGB Order: %s\n" + " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_), - this->max_refresh_rate_); + this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index c499f0a7ca..b2162f641d 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include @@ -18,15 +19,6 @@ namespace esphome::rp2040_pio_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - enum Chipset : uint8_t { CHIPSET_WS2812, CHIPSET_WS2812B, @@ -36,25 +28,6 @@ enum Chipset : uint8_t { CHIPSET_CUSTOM = 0xFF, }; -inline const char *rgb_order_to_string(RGBOrder order) { - switch (order) { - case ORDER_RGB: - return "RGB"; - case ORDER_RBG: - return "RBG"; - case ORDER_GRB: - return "GRB"; - case ORDER_GBR: - return "GBR"; - case ORDER_BGR: - return "BGR"; - case ORDER_BRG: - return "BRG"; - default: - return "UNKNOWN"; - } -} - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) - : traits.set_supported_color_modes({light::ColorMode::RGB}); + this->channel_colors_.has_white() + ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void clear_effect_data() override { for (int i = 0; i < this->size(); i++) { this->effect_data_[i] = 0; @@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } static void dma_write_complete_handler(); @@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t pin_; uint32_t num_leds_; - bool is_rgbw_; pio_hw_t *pio_; uint sm_; uint dma_chan_; dma_channel_config dma_config_; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; uint32_t last_refresh_{0}; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index b3f816102a..9f7479edd0 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import light, rp2 +from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType from esphome.util import _LOGGER @@ -37,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): +def generate_assembly_code(id, t0h, t0l, t1h, t1l): """ Generate assembly code with the given timing values. """ @@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") - Chipset = rp2040_pio_led_strip_ns.enum("Chipset") CHIPSETS = { @@ -159,15 +159,6 @@ class LEDStripTimings: T1L: int -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - CHIPSET_TIMINGS = { "WS2812": LEDStripTimings(20, 40, 46, 34), "WS2812B": LEDStripTimings(23, 49, 46, 26), @@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="rp2040_pio_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) id = config[CONF_ID].id await light.register_light(var, config) @@ -234,8 +230,9 @@ async def to_code(config): cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN])) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_pio(config[CONF_PIO])) cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) @@ -255,7 +252,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], CHIPSET_TIMINGS[chipset].T0H, CHIPSET_TIMINGS[chipset].T0L, CHIPSET_TIMINGS[chipset].T1H, @@ -270,7 +266,6 @@ async def to_code(config): key, generate_assembly_code( id, - config[CONF_IS_RGBW], time_to_cycles(config[CONF_BIT0_HIGH]), time_to_cycles(config[CONF_BIT0_LOW]), time_to_cycles(config[CONF_BIT1_HIGH]), diff --git a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-ard-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml index a071f9df91..d21c4b61b9 100644 --- a/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml +++ b/tests/components/addressable_light/common-idf-esp32_rmt_led_strip.yaml @@ -3,7 +3,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} diff --git a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml index 15409caeaf..2bb831848c 100644 --- a/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml +++ b/tests/components/beken_spi_led_strip/test.bk72xx-ard.yaml @@ -1,6 +1,6 @@ light: - platform: beken_spi_led_strip - rgb_order: GRB + channel_colors: GRB pin: P16 num_leds: 30 chipset: ws2812 diff --git a/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml new file mode 100644 index 0000000000..3ca78398c3 --- /dev/null +++ b/tests/components/beken_spi_led_strip/validate-legacy.bk72xx-ard.yaml @@ -0,0 +1,10 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only, and only one strip because P16 is the sole supported pin. +light: + - platform: beken_spi_led_strip + name: Legacy RGBW + pin: P16 + num_leds: 30 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/e131/common-ard.yaml b/tests/components/e131/common-ard.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-ard.yaml +++ b/tests/components/e131/common-ard.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/common-idf.yaml b/tests/components/e131/common-idf.yaml index 8300dbb01b..48ccafc2d2 100644 --- a/tests/components/e131/common-idf.yaml +++ b/tests/components/e131/common-idf.yaml @@ -5,7 +5,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: ${pin} effects: diff --git a/tests/components/e131/test.rp2040-ard.yaml b/tests/components/e131/test.rp2040-ard.yaml index 4593784ef9..89255e2d87 100644 --- a/tests/components/e131/test.rp2040-ard.yaml +++ b/tests/components/e131/test.rp2040-ard.yaml @@ -6,7 +6,7 @@ light: pin: 2 pio: 0 num_leds: 256 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 effects: - e131: diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 701e513ebd..7f52d32229 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -3,13 +3,13 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgbw_order: RWGB + channel_colors: RWGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 6bf0639a52..132966eddf 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -8,14 +8,14 @@ light: id: led_strip1 pin: ${pin1} num_leds: 60 - rgb_order: GRB + channel_colors: GRB chipset: ws2812 use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 pin: ${pin2} num_leds: 60 - rgb_order: RGB + channel_colors: RGB bit0_high: 100us bit0_low: 100us bit1_high: 100us diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml new file mode 100644 index 0000000000..6dd1bcdad3 --- /dev/null +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -0,0 +1,23 @@ +# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: esp32_rmt_led_strip + id: legacy_rgb + pin: GPIO13 + num_leds: 60 + chipset: ws2812 + rgb_order: GRB # -> GRB + - platform: esp32_rmt_led_strip + id: legacy_rgbw + pin: GPIO14 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_rgbw: true # -> GRBW + - platform: esp32_rmt_led_strip + id: legacy_wrgb + pin: GPIO15 + num_leds: 60 + chipset: sk6812 + rgb_order: GRB + is_wrgb: true # -> WGRB diff --git a/tests/components/partition/common-ard.yaml b/tests/components/partition/common-ard.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-ard.yaml +++ b/tests/components/partition/common-ard.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/common-idf.yaml index b2ceadd6f7..8d39670e32 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/common-idf.yaml @@ -4,7 +4,7 @@ light: default_transition_length: 500ms chipset: ws2812 num_leds: 256 - rgb_order: GRB + channel_colors: GRB pin: ${pin} - platform: partition name: Partition Light diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 254ac0e13d..1cb5fe0737 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -4,14 +4,14 @@ light: pin: 4 num_leds: 60 pio: 0 - rgb_order: GRB + channel_colors: GRB chipset: WS2812 - platform: rp2040_pio_led_strip id: led_strip_custom_timings pin: 5 num_leds: 60 pio: 1 - rgb_order: GRB + channel_colors: GRB bit0_high: .1us bit0_low: 1.2us bit1_high: .69us diff --git a/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml new file mode 100644 index 0000000000..2ab124393b --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/validate-legacy.rp2040-ard.yaml @@ -0,0 +1,18 @@ +# The deprecated rgb_order / is_rgbw keys, kept working until 2027.3.0. +# Config-only: each strip below must migrate to the channel_colors shown in the comment. +light: + - platform: rp2040_pio_led_strip + id: legacy_rgb + pin: 4 + num_leds: 60 + pio: 0 + chipset: WS2812 + rgb_order: GRB # -> GRB + - platform: rp2040_pio_led_strip + id: legacy_rgbw + pin: 5 + num_leds: 60 + pio: 1 + chipset: SK6812 + rgb_order: GRB + is_rgbw: true # -> GRBW diff --git a/tests/components/wled/test.esp32-ard.yaml b/tests/components/wled/test.esp32-ard.yaml index 156b31181e..ecab767812 100644 --- a/tests/components/wled/test.esp32-ard.yaml +++ b/tests/components/wled/test.esp32-ard.yaml @@ -9,7 +9,7 @@ light: id: led_matrix_32x8 default_transition_length: 500ms chipset: ws2812 - rgb_order: GRB + channel_colors: GRB num_leds: 256 pin: 2 effects: diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py new file mode 100644 index 0000000000..0c129a8bb2 --- /dev/null +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -0,0 +1,144 @@ +"""Tests for the shared addressable-strip channel order helpers.""" + +import logging + +import pytest + +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.components.light import ( + channel_colors_struct, + migrate_channel_colors, + validate_channel_colors, +) +import esphome.config_validation as cv +from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER +from esphome.types import ConfigType + +NO_WHITE = "light::ChannelColors::NO_WHITE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", "RGB"), + ("grb", "GRB"), + ("BRG", "BRG"), + ("rgbw", "RGBW"), + ("WRGB", "WRGB"), + ("GWRB", "GWRB"), + ], +) +def test_validate_channel_colors(value: str, expected: str) -> None: + assert validate_channel_colors(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "RG", # missing a channel + "RGBB", # duplicate channel + "RRGB", # duplicate channel, correct length + "RGBWW", # two white channels + "RGBX", # unknown channel + "RGBWX", # unknown channel, correct length + "", + ], +) +def test_validate_channel_colors_rejects_invalid(value: str) -> None: + with pytest.raises(cv.Invalid, match="is not a valid channel order"): + validate_channel_colors(value) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("RGB", (0, 1, 2, NO_WHITE)), + ("GRB", (1, 0, 2, NO_WHITE)), + ("BRG", (1, 2, 0, NO_WHITE)), + ("RGBW", (0, 1, 2, 3)), + ("GRBW", (1, 0, 2, 3)), + ("WRGB", (1, 2, 3, 0)), + ("GWRB", (2, 0, 3, 1)), + ], +) +def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: + struct = channel_colors_struct(value) + assert str(struct.base) == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.args.values()) == tuple( + str(field) for field in expected + ) + + +def _migrate(config: ConfigType) -> ConfigType: + return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config) + + +def test_migrate_passes_through_channel_colors() -> None: + config = {CONF_CHANNEL_COLORS: "GRBW"} + assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"} + + +@pytest.mark.parametrize( + ("deprecated", "expected", "named"), + [ + ({}, "GRB", "'rgb_order' is"), + ( + {CONF_IS_RGBW: False, CONF_IS_WRGB: False}, + "GRB", + "'rgb_order', 'is_rgbw' and 'is_wrgb' are", + ), + ({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"), + ({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"), + ], +) +def test_migrate_folds_deprecated_keys( + deprecated: ConfigType, + expected: str, + named: str, + caplog: pytest.LogCaptureFixture, +) -> None: + config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated} + with caplog.at_level(logging.WARNING): + result = _migrate(config) + + assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1} + assert f"[test_strip] {named} deprecated" in caplog.text + assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text + assert "2027.3.0" in caplog.text + + +def test_migrate_does_not_mutate_input() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + _migrate(config) + assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True} + + +@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB]) +def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None: + config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"} + with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"): + _migrate(config) + + +def test_migrate_reports_every_conflicting_key() -> None: + config = { + CONF_CHANNEL_COLORS: "GRBW", + CONF_RGB_ORDER: "GRB", + CONF_IS_RGBW: True, + CONF_IS_WRGB: False, + } + with pytest.raises( + cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'" + ): + _migrate(config) + + +def test_migrate_requires_channel_colors() -> None: + with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"): + _migrate({"num_leds": 1}) + + +def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None: + config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True} + with pytest.raises(cv.Invalid, match="cannot both be enabled"): + _migrate(config) diff --git a/tests/unit_tests/components/test_esp32_rmt_led_strip.py b/tests/unit_tests/components/test_esp32_rmt_led_strip.py deleted file mode 100644 index e2cb513e3b..0000000000 --- a/tests/unit_tests/components/test_esp32_rmt_led_strip.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from esphome.components.esp32_rmt_led_strip.light import ( - CONF_IS_WRGB, - CONF_RGBW_ORDER, - _split_rgbw_order, - _validate_rgbw_order, - _validate_rgbw_order_exclusivity, -) -import esphome.config_validation as cv -from esphome.const import CONF_IS_RGBW - - -def test_validate_rgbw_order() -> None: - assert _validate_rgbw_order("rwgb") == "RWGB" - - -@pytest.mark.parametrize("rgbw_order", ["RGB", "RRGB", "RGBWW"]) -def test_validate_rgbw_order_rejects_invalid_order(rgbw_order: str) -> None: - with pytest.raises(cv.Invalid, match="permutation of RGBW"): - _validate_rgbw_order(rgbw_order) - - -@pytest.mark.parametrize( - ("rgbw_order", "expected"), - [ - ("WRGB", ("RGB", 0)), - ("RWGB", ("RGB", 1)), - ("GWRB", ("GRB", 1)), - ("RGBW", ("RGB", 3)), - ], -) -def test_split_rgbw_order(rgbw_order: str, expected: tuple[str, int]) -> None: - assert _split_rgbw_order(rgbw_order) == expected - - -@pytest.mark.parametrize("conflict", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_is_mutually_exclusive(conflict: str) -> None: - with pytest.raises(cv.Invalid, match="cannot be used with"): - _validate_rgbw_order_exclusivity( - { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: conflict == CONF_IS_RGBW, - CONF_IS_WRGB: conflict == CONF_IS_WRGB, - } - ) - - -@pytest.mark.parametrize("legacy_option", [CONF_IS_RGBW, CONF_IS_WRGB]) -def test_rgbw_order_allows_disabled_legacy_options(legacy_option: str) -> None: - config = { - CONF_RGBW_ORDER: "RGBW", - CONF_IS_RGBW: False, - CONF_IS_WRGB: False, - } - config[legacy_option] = False - assert _validate_rgbw_order_exclusivity(config) is config From 8b888f31e0bf4d2dedd34fa25d7f1aa593a0c581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH 246/597] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + ) From d1391c2b10a2f473b11d2a69c0ea8e3eecd260c2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:04:49 +1200 Subject: [PATCH 247/597] Bump version to 2026.8.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 2df6d3ded0..3dad4629be 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b4 +PROJECT_NUMBER = 2026.8.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 73155e06ee..e86465f9a0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b4" +__version__ = "2026.8.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9823205ef3080e5e7fd9c4004f3cefc1d68a0e37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:14 -0500 Subject: [PATCH 248/597] [api] Bump noise-c to 0.1.20 (#18482) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index cdc0d97c49..3e69d5842c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.19") + cg.add_library("esphome/noise-c", "0.1.20") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 39600d622a..13bb5a556f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.19 ; used by api + esphome/noise-c@0.1.20 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From ae730d6357e6a82eed82f89a3e396793bf499baf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:33 -0500 Subject: [PATCH 249/597] [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) --- .github/workflows/ci.yml | 17 ++++++++++++++--- tests/benchmarks/components/api/__init__.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd1a382c21..0c81c783b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -464,10 +464,21 @@ jobs: - name: Build benchmarks id: build run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0d02e0b054..0565bc5330 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # components have hardware dependencies (BLE/UART/RMT); lightweight # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_ZWAVE_PROXY") From 476d540065ecd352a5aa4ff52179966d1f732163 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:22:28 -0400 Subject: [PATCH 250/597] [ci] Fall back to files API when PR diff exceeds GitHub line limit (#18486) --- script/helpers.py | 5 +++-- tests/script/test_helpers.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 11549808ff..8132ee49e5 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -558,8 +558,9 @@ def _get_changed_files_github_actions() -> list[str] | None: try: return _get_changed_files_from_command(cmd) except Exception as e: - # If it fails due to the 300 file limit, use the API method - if "maximum" in str(e) and "files" in str(e): + # If it fails due to a diff limit (300 files or 20000 lines), + # use the API method which only returns filenames + if "diff exceeded the maximum" in str(e): cmd = [ "gh", "api", diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index a07e56cea5..2c3ae95655 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -244,6 +244,44 @@ def test_get_changed_files_github_actions_pull_request_large_pr( assert result == expected_files +def test_get_changed_files_github_actions_pull_request_large_diff( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions fallback for PRs with >20000 diff lines.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="17909"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # First call fails with too many diff lines error, second succeeds with API method + mock_get.side_effect = [ + Exception( + "could not find pull request diff: HTTP 406: Sorry, " + "the diff exceeded the maximum number of lines (20000)" + ), + expected_files, + ] + + result = _get_changed_files_github_actions() + + assert mock_get.call_count == 2 + mock_get.assert_any_call(["gh", "pr", "diff", "17909", "--name-only"]) + mock_get.assert_any_call( + [ + "gh", + "api", + "repos/esphome/esphome/pulls/17909/files", + "--paginate", + "--jq", + ".[].filename", + ] + ) + assert result == expected_files + + def test_get_changed_files_github_actions_pull_request_other_error( monkeypatch: MonkeyPatch, ) -> None: From 92f55f721f35d36b4a883811c6cebb6b1027cb7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:32:37 -0500 Subject: [PATCH 251/597] [api] Bump noise-c to 0.1.21 (#18484) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3e69d5842c..912d580a0f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.20") + cg.add_library("esphome/noise-c", "0.1.21") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 13bb5a556f..4c372cc0bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.20 ; used by api + esphome/noise-c@0.1.21 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 285a508e09effe69510fbde25f92b6eb7dd21c03 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 18 Aug 2026 09:09:12 -0700 Subject: [PATCH 252/597] [modbus] CRC scan all unknown function codes (#18483) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/modbus/modbus.cpp | 33 ++-- esphome/components/modbus/modbus.h | 2 +- esphome/components/modbus/modbus_helpers.h | 32 ++++ tests/components/modbus/common.h | 36 +++++ .../modbus/modbus_unknown_function_test.cpp | 141 ++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/components/modbus/modbus_unknown_function_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5305f6313f..e4bd51ad5a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() { this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); } -uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { - // Custom functions could be any length - we have to rely on the CRC to determine completeness. +uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { + // Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values) + // could be any length - we have to rely on the CRC to determine completeness. // If a CRC match is never found, the buffer will eventually overflow and be cleared. const uint8_t *raw = &this->rx_buffer_[0]; const size_t size = this->rx_buffer_.size(); - for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { - if (crc16(raw, len) == 0) - return len; + const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); + if (min_length > max_len) + return 0; + // The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value, + // so we seed once over the first min_length bytes and extend one byte at a time instead of + // recomputing the whole prefix for every candidate length. + uint16_t crc = crc16(raw, min_length); + if (crc == 0) + return min_length; + for (uint16_t len = min_length; len < max_len; len++) { + crc = crc16(&raw[len], 1, crc); + if (crc == 0) + return len + 1; } return 0; } @@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; @@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index dfe4a4872d..bb303c43a8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. - uint16_t find_custom_frame_end_(uint16_t min_length) const; + uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index c737e206c0..b2454e6f14 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } +/// True for any function code whose frame length the parsers cannot predict - everything the +/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list +/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined +/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes +/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. +/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - +/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what +/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary +/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec +/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one +/// pays (recovery by timeout instead of an immediate CRC failure). +inline bool is_function_code_unknown_length(uint8_t function_code) { + switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + case FunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FIFO_QUEUE: + return false; + default: + return true; + } +} + // Returns the expected length of a server response PDU based on the function code. // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index d03ccf8ec3..e6c37b0e6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include #include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus::testing { @@ -30,4 +33,37 @@ class RecordingUART : public NullUART { std::vector written; }; +// A UART the test can inject received bytes into, so frames travel the full receive path +// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded. +class InjectableUART : public RecordingUART { + public: + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + + // Queues a complete wire frame: address + PDU + CRC16 (low byte first). + void inject_frame(uint8_t address, std::span pdu) { + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp new file mode 100644 index 0000000000..8b91d088b8 --- /dev/null +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records custom-response dispatches so tests can assert an unknown-length frame reached the device. +class CustomRecordingDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->statuses.push_back(status); + } + std::vector> requests; + std::vector> responses; + std::vector statuses; +}; + +// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test. +class SilentServerDevice : public ModbusServerDevice {}; + +// Drives full client frames through the server hub's receive path (same shape as the broadcast tests). +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser. + // Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span data) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(data.size() + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end()); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the +// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - +// must classify as unknown length. The exception flag masks off first. +TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { + for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + // Exception replies classify by their base code. + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); + EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); + // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. + for (int fc = 0; fc <= 0xFF; fc++) { + if (helpers::is_function_code_custom(fc)) + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; + } + EXPECT_FALSE(helpers::is_function_code_custom(0x49)); + + // Derived contract check: the helper must say "unknown" exactly when both length parsers fall + // through to default. With a zero-filled max-size PDU every explicit case returns at least 2 + // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The + // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() + // switches on the unmasked byte and server_pdu_length() early-returns the exception length. + for (int fc = 0; fc <= 0x7F; fc++) { + const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "client_pdu_length disagrees for fc 0x" << std::hex << fc; + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "server_pdu_length disagrees for fc 0x" << std::hex << fc; + } +} + +// A response with a function code outside the user-defined ranges (0x49) has no length case in +// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already +// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the +// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device. +TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) { + InjectableUART uart; + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + CustomRecordingDevice device(&hub, 0x02); + + const uint8_t request[] = {0x49, 0x01}; + ASSERT_TRUE(device.queue_pdu(request)); + hub.loop(); // transmit + ASSERT_FALSE(uart.written.empty()); + + const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB}; + uart.inject_frame(0x02, response_pdu); + hub.loop(); // receive + parse + match + dispatch + + ASSERT_EQ(device.responses.size(), 1u); + EXPECT_EQ(device.requests[0], std::vector(request, request + sizeof(request))); + EXPECT_EQ(device.responses[0], std::vector(response_pdu, response_pdu + sizeof(response_pdu))); + EXPECT_FALSE(device.statuses[0].has_value()); +} + +// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC +// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails +// to parse and the client gets silence instead of the exception. +TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + SilentServerDevice device; + device.set_address(0x02); + hub.register_device(&device); + + const uint8_t data[] = {0x02, 0xAA, 0xBB}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data)); + + // Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC. + std::vector expected = {0x02, 0xC9, 0x01}; + uint16_t crc = crc16(expected.data(), expected.size()); + expected.push_back(crc & 0xFF); + expected.push_back(crc >> 8); + EXPECT_EQ(uart.written, expected); +} + +} // namespace esphome::modbus::testing From 5c9d050ebe2ef415484e2c3dc1de61cb26f6b09a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:02:54 +0000 Subject: [PATCH 253/597] Bump aioesphomeapi from 45.10.3 to 45.11.0 (#18493) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a986646230..3d25440671 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.10.3 +aioesphomeapi==45.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 804e8fb856ce5a56a1dd1216f3f3e38273d8b4df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 15:44:38 -0500 Subject: [PATCH 254/597] [socket] Remove constant duplicated by the beta merge (#18496) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b20f79fba1..8d00dbede2 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -50,11 +50,6 @@ static const char *const TAG = "socket"; static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; #endif -#ifdef USE_ESP8266 -// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot. -static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; -#endif - // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) From 0a88c81d95897db3504aca4e623c8fe27daa9a76 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:23 -0500 Subject: [PATCH 255/597] Bump aioesphomeapi from 45.11.0 to 45.12.0 (#18501) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3d25440671..e4521859e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.11.0 +aioesphomeapi==45.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 7a999f9a48f89d9d6561ed5cd46853d5c64f9828 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 256/597] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 6279a26dc4..ce14b0152a 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From 4f866c563b5721a1a9ed1225b6b4fe50ef4f2637 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:36:57 -0500 Subject: [PATCH 257/597] [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) --- esphome/platformio/ccache.py.script | 9 +- esphome/platformio/toolchain.py | 63 +++-- tests/unit_tests/test_platformio_toolchain.py | 240 +++++++++++++++++- 3 files changed, 286 insertions(+), 26 deletions(-) diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script index cc08a8c044..22592a2398 100644 --- a/esphome/platformio/ccache.py.script +++ b/esphome/platformio/ccache.py.script @@ -1,5 +1,4 @@ import os -import shutil # pylint: disable=E0602 Import("env") # noqa @@ -9,15 +8,17 @@ Import("env") # noqa # esphome/platformio/toolchain.py); this script only supplies the SCons-level # mechanism. # +# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has +# already stripped the Windows \\?\ prefix that cmd.exe cannot run. +# # This is a "pre" script, so the platform's builder (which sets CC/CXX and # clones the construction environment for framework and library builds) runs # after it. Replacing CC/CXX here would be overwritten, and replacing them in # a "post" script would miss the already-cloned library environments. Wrapping # SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler # invocation from every environment funnels through it at execution time. -if ( - os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" - and (ccache_path := shutil.which("ccache")) is not None +if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and ( + ccache_path := os.environ.get("ESPHOME_CCACHE_PATH") ): original_spawn = env["SPAWN"] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 08a4fcff78..d76581d032 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -60,6 +60,9 @@ def _strip_win_long_path_prefix(path: str) -> str: "The system cannot find the path specified." Stripping the prefix early keeps the path shell-quotable. + Also applied to the ccache path exported by ``_ccache_env()``, which + ``shutil.which`` can return with the same prefix. + No-op on non-Windows platforms. """ if sys.platform != "win32": @@ -235,8 +238,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_usable() -> bool: - """Return True when the ``ccache`` on PATH actually runs. +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. ``shutil.which`` proves existence, not runnability: on Windows it also matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose @@ -244,9 +247,6 @@ def _ccache_usable() -> bool: step with an opaque OS error, so probe once and fall back to compiling without ccache when the probe fails. """ - ccache = shutil.which("ccache") - if ccache is None: - return False try: subprocess.run( [ccache, "--version"], @@ -265,14 +265,29 @@ def _ccache_usable() -> bool: def _ccache_env() -> dict[str, str]: - """Return ccache settings for PlatformIO builds. + r"""Return ccache settings for PlatformIO builds. Enabled by default whenever the ``ccache`` binary is on PATH; set ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to - force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` - so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, - which wraps compiler invocations inside SCons) only have to check for - ``"1"`` instead of re-implementing the policy. + force it on without the runnability probe; a binary is still needed). + The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the + binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts + (the shared ``ccache.py`` extra script, which wraps compiler invocations + inside SCons) only have to check for ``"1"`` and use the path as given + instead of re-implementing the policy. + + The path is exported rather than looked up again inside SCons because + ``shutil.which`` can return a Windows extended-length ``\\?\`` path + (ESPHome Desktop puts its bundled ccache on PATH that way). Such a path + runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, + but SCons runs every compile through ``cmd.exe``, which fails on it with + "The system cannot find the path specified." (#18399), so the prefix is + stripped here with ``_strip_win_long_path_prefix()`` before the + runnability probe, which therefore validates the exact string the build + will execute. + ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the + script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this + function always sets both or neither. The returned values are merged into the environment of the PlatformIO subprocess only, never into ``os.environ``: a long-running process @@ -293,13 +308,27 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - if "ESPHOME_CCACHE_ENABLE" in os.environ: - enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") - else: - enabled = _ccache_usable() - env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} - if not enabled: - return env + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return {"ESPHOME_CCACHE_ENABLE": "0"} + ccache_path = shutil.which("ccache") + if ccache_path is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return {"ESPHOME_CCACHE_ENABLE": "0"} + # Strip before probing so the probe validates (and the failure warning + # names) the exact string the build will execute through cmd.exe. + ccache_path = _strip_win_long_path_prefix(ccache_path) + # An explicit opt-in skips the runnability probe. + if not explicit and not _ccache_runs(ccache_path): + return {"ESPHOME_CCACHE_ENABLE": "0"} + env = { + "ESPHOME_CCACHE_ENABLE": "1", + "ESPHOME_CCACHE_PATH": ccache_path, + } # build_path is set during preload for every config-loading command, so it # being unset means a caller built the environment too early; fail loudly # rather than with an opaque TypeError from Path(None). diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index eebb0b8cd7..172b288c25 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json @@ -437,6 +437,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert env["CCACHE_DIR"].endswith("platformio-ccache") assert env["CCACHE_NOHASHDIR"] == "true" @@ -446,17 +447,35 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ -def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: - """Ccache stays off when the binary is not on PATH.""" +@pytest.mark.parametrize( + ("env_vars", "expect_warning"), + [ + pytest.param({}, False, id="default"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"), + ], +) +def test_ccache_env_disabled_without_binary( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + env_vars: dict[str, str], + expect_warning: bool, +) -> None: + """Ccache stays off when the binary is not on PATH, even when forced on. + + A deliberate opt-in that finds no binary is downgraded with a warning so + the user can tell why it had no effect; the default path stays quiet. + """ CORE.build_path = setup_core / "build" / "test" with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env_vars, clear=True), patch.object(toolchain.shutil, "which", return_value=None), + caplog.at_level("WARNING"), ): env = toolchain._ccache_env() assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + assert ("no ccache binary is on PATH" in caplog.text) is expect_warning @pytest.mark.parametrize( @@ -489,14 +508,47 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), patch.object(toolchain.subprocess, "run") as mock_probe, ): env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + # The binary's location is still handed to the build script. + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" mock_probe.assert_not_called() +def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: + r"""A ``\\?\`` ccache path from PATH is exported without the prefix. + + That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``. + """ + CORE.build_path = setup_core / "build" / "test" + prefixed = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder" + "\\ccache\\ccache.exe" + ) + stripped = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=True), + # shutil.which is patched, so the win32 code path of the real + # implementation (which crashes on a POSIX host) is never reached. + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch.object(toolchain.shutil, "which", return_value=prefixed), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == stripped + # The probe validates the exact string the build will execute. + assert mock_probe.call_args[0][0] == [stripped, "--version"] + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -516,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -563,8 +615,10 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( env = mock_run_external_process.call_args[1]["env"] assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "ESPHOME_CCACHE_PATH" not in os.environ assert "CCACHE_BASEDIR" not in os.environ @@ -613,6 +667,182 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for ccache.py.""" + + def Replace(self, **kwargs: object) -> None: # noqa: N802 + self.update(kwargs) + + +def _load_ccache_script( + env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None +) -> tuple[_FakeSConsEnv, Callable[..., int]]: + """Run ccache.py.script against a fake SCons env and return (env, original SPAWN).""" + if original_spawn is None: + original_spawn = Mock(name="original_spawn", return_value=0) + scons_env = _FakeSConsEnv(SPAWN=original_spawn) + source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text() + with patch.dict(os.environ, env_vars, clear=True): + exec( # noqa: S102 + compile(source, "ccache.py", "exec"), + {"Import": lambda *_names: None, "env": scons_env}, + ) + return scons_env, original_spawn + + +def _scons_win32_escape(x: str) -> str: + """Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash.""" + if x[-1] == "\\": + x = x + "\\" + return '"' + x + '"' + + +def test_ccache_script_wraps_compiles_with_exported_path() -> None: + """The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup.""" + ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe" + scons_env, original_spawn = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path} + ) + spawn = scons_env["SPAWN"] + assert spawn is not original_spawn + + # A compile step is routed through ccache, with the same path used for + # the program and (escaped) as the first argument. + compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", + _scons_win32_escape, + ccache_path, + [_scons_win32_escape(ccache_path), *compile_args], + {}, + ) + + # Link steps pass through untouched. + original_spawn.reset_mock() + link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {} + ) + + +@pytest.mark.parametrize( + "env_vars", + [ + pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"), + pytest.param({}, id="unset"), + ], +) +def test_ccache_script_leaves_spawn_alone_without_path( + env_vars: dict[str, str], +) -> None: + """Without both the enable flag and a path, SPAWN is not replaced.""" + scons_env, original_spawn = _load_ccache_script(env_vars) + assert scons_env["SPAWN"] is original_spawn + + +def _scons_win32_spawn( + sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict +) -> int: + r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``. + + SCons is not importable in the test environment (PlatformIO fetches it at + build time), so the lines that matter are mirrored here. The command line + SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess`` + instead (identical on Windows, where a string passes through untouched); + ``spawnve`` itself crashes inside pytest. + """ + return subprocess.run( + " ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False + ).returncode + + +_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER" +# Stands in for a compile: the "ccache" is really the Python interpreter, and +# the compile "flags" make it write a marker file so the test can tell whether +# the wrapped command actually ran to completion. +_FAKE_COMPILE_ARGS = [ + "-c", + f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')", +] + + +def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int: + """Run one wrapped compile step the way SCons does on Windows.""" + child_env = {**os.environ, _MARKER_ENV: str(marker)} + return scons_env["SPAWN"]( + os.environ.get("COMSPEC", "cmd.exe"), + _scons_win32_escape, + "xtensa-lx106-elf-gcc", + [_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS], + child_env, + ) + + +_WINDOWS_ONLY = pytest.mark.skipif( + sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows" +) + + +@_WINDOWS_ONLY +def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: + r"""With a ``\\?\`` which result, the real probe runs the stripped binary. + + The probe therefore validates the exact string the build will execute + through ``cmd.exe``; probing the verbatim path instead would pass even + when the stripped path is unusable (``CreateProcess`` accepts + extended-length paths, ``cmd.exe`` does not). + """ + CORE.build_path = setup_core / "build" / "test" + assert not sys.executable.startswith("\\\\?\\") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object( + toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable + ), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == sys.executable + + +@_WINDOWS_ONLY +@pytest.mark.parametrize( + ("prefix", "expect_ok"), + [ + pytest.param("", True, id="stripped-path-compiles"), + pytest.param("\\\\?\\", False, id="verbatim-path-fails"), + ], +) +def test_ccache_wrapper_through_cmd_exe( + tmp_path: Path, prefix: str, expect_ok: bool +) -> None: + r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not. + + The interpreter stands in for ccache; the spawn mirrors SCons on Windows. + The failing case is the mechanism behind #18399 ("The system cannot find + the path specified." on every compile step); should it ever start passing, + ``cmd.exe`` learned extended-length paths and the strip is no longer needed. + """ + marker = tmp_path / "compiled.txt" + scons_env, _ = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable}, + original_spawn=_scons_win32_spawn, + ) + assert scons_env["SPAWN"] is not _scons_win32_spawn + + rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker) + assert (rc == 0) is expect_ok + assert marker.exists() is expect_ok + if expect_ok: + assert marker.read_text() == "compiled" + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ From 8b637b339b109ceaab298dad6f748a4671afd420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:37:10 -0500 Subject: [PATCH 258/597] [vscode] Report the origin of an unexpected exception during validation (#18494) --- esphome/vscode.py | 20 +++++++++- tests/unit_tests/test_vscode.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f00..ba7b4e727b 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -3,12 +3,14 @@ from __future__ import annotations from io import StringIO import json from pathlib import Path +import sys +import traceback from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, DocumentRange +from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml @@ -97,6 +99,16 @@ def _ace_loader(fname: Path) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream) +def _format_unexpected_error(err: Exception) -> str: + """Describe a crash inside validation with the frame it came from.""" + message = f"Unexpected error while validating: {type(err).__name__}: {err}" + frames = traceback.extract_tb(err.__traceback__) + if not frames: + return message + frame = frames[-1] + return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})" + + def _print_version(): """Print ESPHome version.""" print( @@ -134,8 +146,12 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + except (EsphomeError, cv.Invalid) as err: vs.add_yaml_error(str(err)) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # stdout carries the JSON protocol; the full chain goes to stderr. + traceback.print_exc(file=sys.stderr) + vs.add_yaml_error(_format_unexpected_error(err)) else: for err in res.errors: try: diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 63bdf3e255..9b7d1e9504 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode +import esphome.config_validation as cv +from esphome.core import EsphomeError def _run_repl_test(input_data): @@ -126,3 +128,67 @@ packages: assert range["start_col"] == 2 assert range["end_line"] == 1 assert range["end_col"] == 7 + + +def _explode(*_args: object, **_kwargs: object) -> None: + raise AttributeError("'NoneType' object has no attribute 'get'") + + +def test_unexpected_error_reports_origin() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", _explode): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["validation_errors"] == [] + (error,) = result["yaml_errors"] + assert error["message"].startswith( + "Unexpected error while validating: AttributeError: " + "'NoneType' object has no attribute 'get' (" + ) + assert "test_vscode.py" in error["message"] + assert error["message"].endswith(" in _explode)") + + +def test_esphome_error_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "boom"}] + + +def test_invalid_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "bad value"}] + + +def test_format_unexpected_error_without_traceback() -> None: + message = vscode._format_unexpected_error(ValueError("boom")) + assert message == "Unexpected error while validating: ValueError: boom" From 8aa7db15e52e7842edb4e0ce634c58028b20f4fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 259/597] [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) --- esphome/components/esp32/__init__.py | 78 +++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7d43c3ac07..3065cdadad 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1073,6 +1073,26 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) @@ -1085,6 +1105,8 @@ def _detect_variant(value): # name rather than carrying a PIO board name through the IDF build. if CORE.using_toolchain_esp_idf: value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() return value if variant not in STANDARD_BOARDS: @@ -1095,22 +1117,8 @@ def _detect_variant(value): ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -1120,6 +1128,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -1131,6 +1147,9 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value @@ -1434,20 +1453,6 @@ def final_validate(config) -> None: path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -2518,15 +2523,14 @@ async def to_code(config): f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True ) - # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 - # from y to n. PlatformIO uses sections.ld.in (for rev <3) or - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, From 07fa16e2e74f9964da91147028b630a7436b5d0e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:38:18 -0400 Subject: [PATCH 260/597] [ci] Stop persisting the integration test ccache (#18504) --- .github/workflows/ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c81c783b5..c3f830a5aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,18 +341,6 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache - - name: Restore ccache (restore-only) - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). The - # bucket-name prefix prefers a same-bucket seed; the bare prefix falls - # back to any seed when the bucket layout differs from dev. - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - restore-keys: | - integration-ccache-${{ matrix.bucket.name }}- - integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -401,14 +389,6 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save ccache - # Pull request saves land in per-PR scopes nothing else can reuse; - # dev pushes seed the shared copy instead. - if: github.event_name != 'pull_request' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} import-time: name: Check import esphome.__main__ time From b7121940c85ca166fd344d5c6b3c37f49e228a24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:08:23 -0400 Subject: [PATCH 261/597] Update wheel requirement from <0.48,>=0.43 to >=0.43,<0.49 (#18459) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index afa6208cae..3185fe0a9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.49"] build-backend = "setuptools.build_meta" [project] From 17eed7055bf516797478e3c12724758e05e8f94c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:22:51 -0400 Subject: [PATCH 262/597] Bump resvg-py from 0.3.4 to 0.4.0 (#18460) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e4521859e7..740a8c1a79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.3.0 -resvg-py==0.3.4 +resvg-py==0.4.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 From b7cc271219467909b28f81f244710bc06b81f79f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:51 -0500 Subject: [PATCH 263/597] Bump bundled esphome-device-builder to 1.11.3 (#18505) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a8daeaaf6..50b698224c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 RUN \ platformio settings set enable_telemetry No \ From d2c3f749abb87fdc4f7740ef5f05b4d33772de77 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:23 -0500 Subject: [PATCH 264/597] Bump bundled esphome-device-builder to 1.11.4 (#18506) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50b698224c..5c21e07618 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 RUN \ platformio settings set enable_telemetry No \ From e26237e57d66ea9d8e064323bd9d12a83790499f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 -0500 Subject: [PATCH 265/597] Bump bundled esphome-device-builder to 1.11.5 (#18507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5c21e07618..1be10db3af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 RUN \ platformio settings set enable_telemetry No \ From f90b7760714a96a396bcda158bd7465b65888b69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 11:39:01 -0500 Subject: [PATCH 266/597] [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3f830a5aa..6afb8a9d22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,24 +545,29 @@ jobs: fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf From 7b7107556f4637c157a6bbdbae5bbd80cbd5f3ee Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:58 -0500 Subject: [PATCH 267/597] Bump bundled esphome-device-builder to 1.12.0 (#18514) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1be10db3af..18f705b501 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 RUN \ platformio settings set enable_telemetry No \ From 470226ca03dfee7b8b6d08589a15a237bba12499 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:12 -0700 Subject: [PATCH 268/597] [image] Restore defaults:/files: support for platform entries (#18032) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 3 +- esphome/components/image/__init__.py | 152 +++++++- esphome/components/runtime_image/__init__.py | 5 +- esphome/config.py | 17 + esphome/loader.py | 8 + tests/component_tests/image/test_init.py | 328 +++++++++++++++++- .../validate-platform-defaults.host.yaml | 21 ++ .../validate-platform-defaults.host.yaml | 24 ++ tests/unit_tests/test_config_normalization.py | 124 ++++++- 9 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 tests/components/animation/validate-platform-defaults.host.yaml create mode 100644 tests/components/image/validate-platform-defaults.host.yaml diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index d340d21490..feced063d0 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -23,6 +23,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -200,7 +201,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84d..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: return get_all_image_metadata().get(image_id) +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + # --------------------------------------------------------------------------- # Legacy top-level component -> `image:` platform deprecation helpers # -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. return bool(config) and all( - isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. return False # A single image dict, or the grouped `defaults:`/`images:`/type-key form. return ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(merged, {})) def _add_entries(entries: object, extra: dict) -> None: # `entries` may be a single image dict or a list of them; non-dict diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index d8517d4493..9fa32a5a65 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER from esphome.components.image import ( IMAGE_TYPE, Image_, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), cv.Optional(CONF_RESIZE): cv.dimensions, cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "BIG_ENDIAN", "LITTLE_ENDIAN", upper=True - ), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(), cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), } diff --git a/esphome/config.py b/esphome/config.py index 987bb9c96a..13ec744ce4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep): elif not isinstance(self.conf, list): result[self.domain] = self.conf = [self.conf] + # Permanent expansion hook: a platform-tagged entry may expand into + # several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only. + if (expand := component.expand_platform_config) is not None and all( + isinstance(entry, dict) and CONF_PLATFORM in entry + for entry in self.conf + ): + with result.catch_error(path): + expanded = expand(self.conf) + if not isinstance(expanded, list): + # A non-list return is a component bug (not a user error): + # raise explicitly (survives -O/-OO) so it escapes catch_error. + raise TypeError( + f"{self.domain}: EXPAND_PLATFORM_CONFIG must " + f"return a list, got {type(expanded).__name__}" + ) + result[self.domain] = self.conf = expanded + # Process AUTO_LOAD _process_auto_load(result, component, path) diff --git a/esphome/loader.py b/esphome/loader.py index f994f0c5eb..23c6d1bfa5 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -164,6 +164,14 @@ class ComponentManifest: """ return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property + def expand_platform_config( + self, + ) -> Callable[[list[ConfigType]], list[ConfigType]] | None: + """Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged + entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors).""" + return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f52c477c85..fad8b7df09 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -21,16 +21,20 @@ from esphome.components.image import ( CONF_OPAQUE, CONF_TRANSPARENCY, PLATFORM_FILE, + _expand_platform_entry, _flatten_legacy_image_config, _is_legacy_image_format, _is_new_image_format, _migrate_legacy_image_config, + expand_platform_config, get_all_image_metadata, get_image_metadata, ) from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, + CONF_FILES, CONF_ID, CONF_PLATFORM, CONF_RAW_DATA_ID, @@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None: assert out[0][CONF_BYTE_ORDER] == "little_endian" +def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None: + """The legacy flattener drops an incompatible byte_order even when written directly on the entry.""" + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + def test_flatten_skips_meta_and_unknown_keys() -> None: out = _flatten_legacy_image_config( { @@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform( ), pytest.param({"foo": 1}, False, id="dict_unknown_keys"), pytest.param("a string", False, id="scalar"), + # A `platform:`-tagged dict is the new format written without list brackets. + pytest.param( + {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}, + False, + id="platform_tagged_flat_dict", + ), + pytest.param( + { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="platform_tagged_defaults_files_dict", + ), + # `files:` without `platform:` is not legacy either -- the flattener has no branch for it. + pytest.param( + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="defaults_files_dict_without_platform", + ), + # Same as above in a list -- without this exclusion it would be silently + # migrated to a hard-coded `platform: file` instead of raising the error. + pytest.param( + [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + } + ], + False, + id="defaults_files_list_entry_without_platform", + ), ], ) def test_is_legacy_image_format(config: object, expected: bool) -> None: @@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None: def test_migrate_returns_none_for_invalid_legacy_shapes( config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Unrecognised shapes are not migrated (and emit no warning) so normal - platform validation surfaces a proper error instead of silently dropping - the offending input.""" + """Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them.""" with caplog.at_level(logging.WARNING): assert _migrate_legacy_image_config(config) is None assert "deprecated" not in caplog.text +def test_migrate_returns_none_for_mapping_form_defaults_files() -> None: + """A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator.""" + config = { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None: + """`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has + no `files:` branch and would silently return `[]`.""" + config = { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None: + """Same, in a list -- previously the list branch migrated it to a hard-coded + `platform: file` instead of raising a missing-platform error.""" + config = [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + ] + assert _migrate_legacy_image_config(config) is None + + # --------------------------- end legacy migration -------------------------- +def test_expand_platform_entry_passes_through_plain_entry() -> None: + entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"} + assert _expand_platform_entry(0, entry) == [entry] + + +def test_expand_platform_entry_expands_files_with_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png", "type": "GRAYSCALE"}, + ], + } + assert _expand_platform_entry(0, entry) == [ + { + CONF_PLATFORM: "file", + "id": "img1", + "file": "foo.png", + "type": "RGB565", + "transparency": "opaque", + }, + { + CONF_PLATFORM: "file", + "id": "img2", + "file": "bar.png", + "type": "GRAYSCALE", + "transparency": "opaque", + }, + ] + + +def test_expand_platform_entry_files_without_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + assert _expand_platform_entry(0, entry) == [ + {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + ] + + +def test_expand_platform_entry_preserves_source_range() -> None: + """A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there.""" + from esphome import yaml_util + + file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"}) + file_entry._esp_range = "sentinel-range" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [file_entry], + } + [out] = _expand_platform_entry(0, entry) + assert isinstance(out, yaml_util.ESPHomeDataBase) + assert out.esp_range == "sentinel-range" + + +def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None: + """Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + [out] = _expand_platform_entry(0, entry) + assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + + +def test_expand_platform_entry_per_file_overrides_win() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["type"] == "BINARY" + + +def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None: + """A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"}, + CONF_FILES: [ + {"id": "a", "file": "x.png"}, + {"id": "b", "file": "y.png", "type": "binary"}, + ], + } + out = _expand_platform_entry(0, entry) + assert out[0]["byte_order"] == "little_endian" + assert "byte_order" not in out[1] + + +def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None: + """A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}], + } + with pytest.raises(cv.Invalid, match="did you mean") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "big_endian" + + +def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None: + """A `byte_order` written directly on the entry is kept so validate_settings raises the normal error.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565"}, + CONF_FILES: [ + { + "id": "a", + "file": "x.png", + "type": "binary", + "byte_order": "little_endian", + } + ], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "little_endian" + + +def test_expand_platform_entry_defaults_without_files_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}} + with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_null_files_raises_not_empty() -> None: + """A `files:` key with no value parses to `None` and must be reported clearly.""" + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None: + """An explicit `files: []` must not silently drop the whole platform entry.""" + entry = {CONF_PLATFORM: "file", CONF_FILES: []} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_with_stray_key_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png"}], + "extra": 1, + } + with pytest.raises(cv.Invalid, match="cannot be combined with"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_id_in_defaults_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_ID: "a"}, + CONF_FILES: [{"file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_defaults_raises() -> None: + """`platform:` inside `defaults:` would silently reassign every file's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_PLATFORM: "animation"}, + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_file_entry_raises() -> None: + """`platform:` on a `files:` item must not silently override the entry's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_not_list_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"} + with pytest.raises(cv.Invalid, match="must be a list"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_defaults_not_mapping_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: "not-a-mapping", + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_file_item_not_mapping_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]} + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None: + config = [ + { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png"}, + ], + }, + {CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"}, + ] + out = expand_platform_config(config) + assert [entry["id"] for entry in out] == ["img1", "img2", "plain"] + + +def test_expand_platform_config_ignores_non_platform_entries() -> None: + # Not expanded here -- legacy_config_migrate runs before this hook and is + # responsible for tagging/flattening pre-platform shapes. + config = ["not-a-platform-entry"] + assert expand_platform_config(config) == config + + +# --------------------- end defaults/files expansion ------------------------- + + def test_validate_image_final_defaults_to_little_endian() -> None: config = {CONF_FILE: "x.png"} validate_image_final(config) diff --git a/tests/components/animation/validate-platform-defaults.host.yaml b/tests/components/animation/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..034497c548 --- /dev/null +++ b/tests/components/animation/validate-platform-defaults.host.yaml @@ -0,0 +1,21 @@ +# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: animation + defaults: + type: rgb565 + transparency: opaque + resize: 50x50 + files: + - id: platform_defaults_animation + file: $component_dir/anim.gif + - id: platform_defaults_animation_rgb + file: $component_dir/anim.apng + type: rgb diff --git a/tests/components/image/validate-platform-defaults.host.yaml b/tests/components/image/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..e1b3037cc3 --- /dev/null +++ b/tests/components/image/validate-platform-defaults.host.yaml @@ -0,0 +1,24 @@ +# `platform: file` entry using the `defaults:`/`files:` shape, including the +# per-type byte_order drop when an entry overrides to a non-endian type. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: file + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + files: + - id: platform_defaults_image + file: ../../pnglogo.png + - id: platform_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index c8b7b63094..04363ad45b 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import config, yaml_util +from esphome import config, config_validation as cv, yaml_util from esphome.core import CORE, AutoLoad from esphome.types import ConfigType @@ -127,12 +127,14 @@ def _run_load_step( domain: str, conf: object, migrate: Callable[[ConfigType], list | None] | None, + expand: Callable[[list], list] | None = None, ) -> config.Config: - """Run a LoadValidationStep for a platform component with a given migrate hook.""" + """Run a LoadValidationStep for a platform component with given hooks.""" component = Mock() component.is_platform_component = True component.multi_conf_no_default = False component.legacy_config_migrate = migrate + component.expand_platform_config = expand result = config.Config() with ( @@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None: assert result["image"] == [auto] +# --------------------------------------------------------------------------- +# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart +# to legacy_config_migrate; runs after legacy migration/list normalization. +# --------------------------------------------------------------------------- + + +def test_expand_hook_rewrites_conf() -> None: + """A config the expand hook rewrites is replaced with the expanded list.""" + expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}] + expand = Mock(return_value=expanded) + + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + expand.assert_called_once_with([{"platform": "file", "id": "a"}]) + assert result["image"] == expanded + + +def test_expand_hook_absent_is_noop() -> None: + """A platform component without the hook is left as normalized by the + existing list-wrapping logic.""" + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None) + + assert result["image"] == [{"platform": "file", "id": "a"}] + + +def test_expand_hook_runs_after_legacy_migrate() -> None: + """The expand hook sees the already-migrated list, not the raw legacy conf.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + expand = Mock(side_effect=lambda conf: conf) + + _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand) + + expand.assert_called_once_with(migrated) + + +def test_expand_hook_skipped_for_non_dict_entry() -> None: + """Malformed entries are left alone; the hook only sees `platform:`-tagged dicts.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", ["not-a-dict"], None, expand) + + expand.assert_not_called() + assert result["image"] == ["not-a-dict"] + + +def test_expand_hook_skipped_for_entry_missing_platform_key() -> None: + """A dict entry missing the `platform:` key is left alone -- the normal + per-entry error reporting further down catches this case instead.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", [{"id": "a"}], None, expand) + + expand.assert_not_called() + assert result["image"] == [{"id": "a"}] + + +def test_expand_hook_skipped_for_autoload() -> None: + """A non-empty AutoLoad reaching the hook stage is left alone.""" + expand = Mock(side_effect=lambda conf: conf) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, None, expand) + + expand.assert_not_called() + assert result["image"] == [auto] + + +def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None: + """The guard does not block the normal, well-formed case.""" + expand = Mock(side_effect=lambda conf: conf) + conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}] + + result = _run_load_step("image", conf, None, expand) + + expand.assert_called_once_with(conf) + assert result["image"] == conf + + +def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None: + """A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs.""" + expand = Mock(side_effect=cv.Invalid("bad shape")) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0].path == ["image"] + assert "bad shape" in str(result.errors[0]) + assert result["image"] == pre_expand_conf + + +def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None: + """`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended).""" + already_resolved_error = cv.FinalExternalInvalid( + "bad shape", path=["image", 3, "files"] + ) + expand = Mock(side_effect=already_resolved_error) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0] is already_resolved_error + assert result.errors[0].path == ["image", 3, "files"] + assert result["image"] == pre_expand_conf + + +def test_expand_hook_non_list_return_raises_type_error() -> None: + """A non-list return is a component bug: it escapes as an uncaught TypeError + (explicit raise survives -O/-OO).""" + expand = Mock(return_value={"not": "a list"}) + + with pytest.raises(TypeError, match="must return a list"): + _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 47da743d11ec42374a9026d8b473174d20489077 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:09:12 -0700 Subject: [PATCH 269/597] [ai] Add instructions for concise comments (#18522) --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fa0f61c263..f006ee6087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -763,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, using standard technical terms only when required. Ensure the text is readily comprehensible to a wide audience, including non-native English speakers. + +## 10. Code Comments + +Code comments on individual lines should be used only where necessary to flag issues that may not be obvious +on a simple reading of the code. Keep them short (e.g. 1 or 2 lines). + +Function and method comment blocks may include more detail as required to make +calling contracts clear and document parameter usage, but should still be kept concise. + +Avoid redundancy and repetition; comments should never simply restate what the code already says. From 0b1065feee095c82220fa4e9d1cd6b3b164aa82a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 16:30:34 -0500 Subject: [PATCH 270/597] [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) --- .github/workflows/ci-api-proto.yml | 28 +++++++++- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1ccff96f24..63219a1dbc 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -41,10 +41,32 @@ jobs: version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afb8a9d22..35148de0c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -323,7 +339,8 @@ jobs: integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # Must match seed-apt-cache's image: the apt cache key has no OS in it. + runs-on: ubuntu-24.04 needs: - common - determine-jobs @@ -335,12 +352,16 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install ccache - # Speeds up the host compiles: tests in a bucket compile overlapping - # component sets, so later tests reuse earlier tests' objects. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ccache + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -421,6 +442,7 @@ jobs: benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs @@ -461,6 +483,41 @@ jobs: fi echo "binary=$BINARY" >> $GITHUB_OUTPUT + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true + run: | + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: @@ -884,12 +941,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1424,6 +1486,7 @@ jobs: # this check. needs: - common + - seed-apt-cache - determine-jobs - ci-custom - pylint From 9daae377fca5eea6d1f39d13fa33e790d50b2f9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:14 -0500 Subject: [PATCH 271/597] [api] Bump noise-c to 0.1.20 (#18482) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index cdc0d97c49..3e69d5842c 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.19") + cg.add_library("esphome/noise-c", "0.1.20") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 39600d622a..13bb5a556f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.19 ; api + esphome/noise-c@0.1.20 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.19 ; used by api + esphome/noise-c@0.1.20 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 200a1644a5d12c30e4f0d562f1e0132b96482dc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:03:33 -0500 Subject: [PATCH 272/597] [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) --- .github/workflows/ci.yml | 17 ++++++++++++++--- tests/benchmarks/components/api/__init__.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b603e68ad7..2075fde9ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,10 +460,21 @@ jobs: - name: Build benchmarks id: build run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0d02e0b054..0565bc5330 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # components have hardware dependencies (BLE/UART/RMT); lightweight # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_ZWAVE_PROXY") From a99a8f364e8bad032d89d65e18e2470fd2df2267 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:32:37 -0500 Subject: [PATCH 273/597] [api] Bump noise-c to 0.1.21 (#18484) --- esphome/components/api/__init__.py | 2 +- platformio.ini | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3e69d5842c..912d580a0f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.20") + cg.add_library("esphome/noise-c", "0.1.21") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 13bb5a556f..4c372cc0bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.20 ; api + esphome/noise-c@0.1.21 ; api ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.20 ; used by api + esphome/noise-c@0.1.21 ; used by api lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From 10e592fa3a51b301644b5a742c75088cc3ab286a Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 18 Aug 2026 09:09:12 -0700 Subject: [PATCH 274/597] [modbus] CRC scan all unknown function codes (#18483) Co-authored-by: Claude Opus 4.8 (1M context) --- esphome/components/modbus/modbus.cpp | 33 ++-- esphome/components/modbus/modbus.h | 2 +- esphome/components/modbus/modbus_helpers.h | 32 ++++ tests/components/modbus/common.h | 36 +++++ .../modbus/modbus_unknown_function_test.cpp | 141 ++++++++++++++++++ 5 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 tests/components/modbus/modbus_unknown_function_test.cpp diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 5305f6313f..e4bd51ad5a 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() { this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); } -uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { - // Custom functions could be any length - we have to rely on the CRC to determine completeness. +uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const { + // Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values) + // could be any length - we have to rely on the CRC to determine completeness. // If a CRC match is never found, the buffer will eventually overflow and be cleared. const uint8_t *raw = &this->rx_buffer_[0]; const size_t size = this->rx_buffer_.size(); - for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { - if (crc16(raw, len) == 0) - return len; + const auto max_len = static_cast(std::min(size, size_t(MAX_FRAME_SIZE))); + if (min_length > max_len) + return 0; + // The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value, + // so we seed once over the first min_length bytes and extend one byte at a time instead of + // recomputing the whole prefix for every candidate length. + uint16_t crc = crc16(raw, min_length); + if (crc == 0) + return min_length; + for (uint16_t len = min_length; len < max_len; len++) { + crc = crc16(&raw[len], 1, crc); + if (crc == 0) + return len + 1; } return 0; } @@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; @@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() { uint8_t address = this->rx_buffer_[0]; uint8_t function_code = this->rx_buffer_[1]; - if (helpers::is_function_code_custom(function_code)) { - frame_length = this->find_custom_frame_end_(frame_length); + if (helpers::is_function_code_unknown_length(function_code)) { + frame_length = this->find_frame_end_by_crc_(frame_length); if (frame_length == 0) return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size - ESP_LOGD(TAG, "User-defined function %02X found", function_code); + ESP_LOGD(TAG, "Unknown-length function %02X found", function_code); } else { if (crc16(&this->rx_buffer_[0], frame_length) != 0) return false; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index dfe4a4872d..bb303c43a8 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component { bool send_frame_(const ModbusFrame &frame); // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. - uint16_t find_custom_frame_end_(uint16_t min_length) const; + uint16_t find_frame_end_by_crc_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; uint32_t last_receive_check_{0}; diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index c737e206c0..b2454e6f14 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) { masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); } +/// True for any function code whose frame length the parsers cannot predict - everything the +/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list +/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined +/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes +/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value. +/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code - +/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what +/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary +/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec +/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one +/// pays (recovery by timeout instead of an immediate CRC failure). +inline bool is_function_code_unknown_length(uint8_t function_code) { + switch (static_cast(function_code & FUNCTION_CODE_MASK)) { + case FunctionCode::READ_COILS: + case FunctionCode::READ_DISCRETE_INPUTS: + case FunctionCode::READ_HOLDING_REGISTERS: + case FunctionCode::READ_INPUT_REGISTERS: + case FunctionCode::WRITE_SINGLE_COIL: + case FunctionCode::WRITE_SINGLE_REGISTER: + case FunctionCode::WRITE_MULTIPLE_COILS: + case FunctionCode::WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FILE_RECORD: + case FunctionCode::WRITE_FILE_RECORD: + case FunctionCode::MASK_WRITE_REGISTER: + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + case FunctionCode::READ_FIFO_QUEUE: + return false; + default: + return true; + } +} + // Returns the expected length of a server response PDU based on the function code. // If too few bytes have arrived to determine the length, returns the minimum length. `size` is the // number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC diff --git a/tests/components/modbus/common.h b/tests/components/modbus/common.h index d03ccf8ec3..e6c37b0e6d 100644 --- a/tests/components/modbus/common.h +++ b/tests/components/modbus/common.h @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include #include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus::testing { @@ -30,4 +33,37 @@ class RecordingUART : public NullUART { std::vector written; }; +// A UART the test can inject received bytes into, so frames travel the full receive path +// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded. +class InjectableUART : public RecordingUART { + public: + bool peek_byte(uint8_t *data) override { + if (this->rx_.empty()) + return false; + *data = this->rx_.front(); + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (len > this->rx_.size()) + return false; + memcpy(data, this->rx_.data(), len); + this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len); + return true; + } + size_t available() override { return this->rx_.size(); } + + // Queues a complete wire frame: address + PDU + CRC16 (low byte first). + void inject_frame(uint8_t address, std::span pdu) { + size_t start = this->rx_.size(); + this->rx_.push_back(address); + this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end()); + uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start); + this->rx_.push_back(crc & 0xFF); + this->rx_.push_back(crc >> 8); + } + + private: + std::vector rx_; +}; + } // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_unknown_function_test.cpp b/tests/components/modbus/modbus_unknown_function_test.cpp new file mode 100644 index 0000000000..8b91d088b8 --- /dev/null +++ b/tests/components/modbus/modbus_unknown_function_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include "common.h" +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Records custom-response dispatches so tests can assert an unknown-length frame reached the device. +class CustomRecordingDevice : public ModbusClientDevice { + public: + using ModbusClientDevice::ModbusClientDevice; + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->requests.emplace_back(request_pdu.begin(), request_pdu.end()); + this->responses.emplace_back(response_pdu.begin(), response_pdu.end()); + this->statuses.push_back(status); + } + std::vector> requests; + std::vector> responses; + std::vector statuses; +}; + +// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test. +class SilentServerDevice : public ModbusServerDevice {}; + +// Drives full client frames through the server hub's receive path (same shape as the broadcast tests). +class TestServerHub : public ModbusServerHub { + public: + bool tx_blocked() override { return false; } + + // Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser. + // Returns true once the buffer has fully drained. + bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span data) { + this->rx_buffer_.clear(); + this->rx_buffer_.reserve(data.size() + 4); + this->rx_buffer_.push_back(address); + this->rx_buffer_.push_back(function_code); + this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end()); + uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size()); + this->rx_buffer_.push_back(crc & 0xFF); + this->rx_buffer_.push_back(crc >> 8); + this->parse_modbus_frames(); + return this->rx_buffer_.empty(); + } +}; + +} // namespace + +// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the +// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes - +// must classify as unknown length. The exception flag masks off first. +TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) { + for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) { + EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) { + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc); + } + // Exception replies classify by their base code. + EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83)); + EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87)); + // Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa. + for (int fc = 0; fc <= 0xFF; fc++) { + if (helpers::is_function_code_custom(fc)) + EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc; + } + EXPECT_FALSE(helpers::is_function_code_custom(0x49)); + + // Derived contract check: the helper must say "unknown" exactly when both length parsers fall + // through to default. With a zero-filled max-size PDU every explicit case returns at least 2 + // (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing + // against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The + // loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length() + // switches on the unmasked byte and server_pdu_length() early-returns the exception length. + for (int fc = 0; fc <= 0x7F; fc++) { + const uint8_t pdu[MAX_PDU_SIZE] = {static_cast(fc)}; // zero header fields + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "client_pdu_length disagrees for fc 0x" << std::hex << fc; + EXPECT_EQ(helpers::is_function_code_unknown_length(fc), + helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE) + << "server_pdu_length disagrees for fc 0x" << std::hex << fc; + } +} + +// A response with a function code outside the user-defined ranges (0x49) has no length case in +// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already +// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the +// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device. +TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) { + InjectableUART uart; + ModbusClientHub hub; + hub.set_uart_parent(&uart); + hub.setup(); // computes frame timing from the baud rate + CustomRecordingDevice device(&hub, 0x02); + + const uint8_t request[] = {0x49, 0x01}; + ASSERT_TRUE(device.queue_pdu(request)); + hub.loop(); // transmit + ASSERT_FALSE(uart.written.empty()); + + const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB}; + uart.inject_frame(0x02, response_pdu); + hub.loop(); // receive + parse + match + dispatch + + ASSERT_EQ(device.responses.size(), 1u); + EXPECT_EQ(device.requests[0], std::vector(request, request + sizeof(request))); + EXPECT_EQ(device.responses[0], std::vector(response_pdu, response_pdu + sizeof(response_pdu))); + EXPECT_FALSE(device.statuses[0].has_value()); +} + +// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC +// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails +// to parse and the client gets silence instead of the exception. +TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) { + TestServerHub hub; + RecordingUART uart; + hub.set_uart_parent(&uart); + + SilentServerDevice device; + device.set_address(0x02); + hub.register_device(&device); + + const uint8_t data[] = {0x02, 0xAA, 0xBB}; + ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data)); + + // Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC. + std::vector expected = {0x02, 0xC9, 0x01}; + uint16_t crc = crc16(expected.data(), expected.size()); + expected.push_back(crc & 0xFF); + expected.push_back(crc >> 8); + EXPECT_EQ(uart.written, expected); +} + +} // namespace esphome::modbus::testing From 2df953f3d7c0b022cd3a75c05ab2ca0d63cc39f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:36:57 -0500 Subject: [PATCH 275/597] [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) --- esphome/platformio/ccache.py.script | 9 +- esphome/platformio/toolchain.py | 63 +++-- tests/unit_tests/test_platformio_toolchain.py | 240 +++++++++++++++++- 3 files changed, 286 insertions(+), 26 deletions(-) diff --git a/esphome/platformio/ccache.py.script b/esphome/platformio/ccache.py.script index cc08a8c044..22592a2398 100644 --- a/esphome/platformio/ccache.py.script +++ b/esphome/platformio/ccache.py.script @@ -1,5 +1,4 @@ import os -import shutil # pylint: disable=E0602 Import("env") # noqa @@ -9,15 +8,17 @@ Import("env") # noqa # esphome/platformio/toolchain.py); this script only supplies the SCons-level # mechanism. # +# The binary comes pre-resolved in ESPHOME_CCACHE_PATH; _ccache_env() has +# already stripped the Windows \\?\ prefix that cmd.exe cannot run. +# # This is a "pre" script, so the platform's builder (which sets CC/CXX and # clones the construction environment for framework and library builds) runs # after it. Replacing CC/CXX here would be overwritten, and replacing them in # a "post" script would miss the already-cloned library environments. Wrapping # SPAWN instead is ordering-proof: clones copy the wrapper, and every compiler # invocation from every environment funnels through it at execution time. -if ( - os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" - and (ccache_path := shutil.which("ccache")) is not None +if os.environ.get("ESPHOME_CCACHE_ENABLE") == "1" and ( + ccache_path := os.environ.get("ESPHOME_CCACHE_PATH") ): original_spawn = env["SPAWN"] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 08a4fcff78..d76581d032 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -60,6 +60,9 @@ def _strip_win_long_path_prefix(path: str) -> str: "The system cannot find the path specified." Stripping the prefix early keeps the path shell-quotable. + Also applied to the ccache path exported by ``_ccache_env()``, which + ``shutil.which`` can return with the same prefix. + No-op on non-Windows platforms. """ if sys.platform != "win32": @@ -235,8 +238,8 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_usable() -> bool: - """Return True when the ``ccache`` on PATH actually runs. +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs. ``shutil.which`` proves existence, not runnability: on Windows it also matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose @@ -244,9 +247,6 @@ def _ccache_usable() -> bool: step with an opaque OS error, so probe once and fall back to compiling without ccache when the probe fails. """ - ccache = shutil.which("ccache") - if ccache is None: - return False try: subprocess.run( [ccache, "--version"], @@ -265,14 +265,29 @@ def _ccache_usable() -> bool: def _ccache_env() -> dict[str, str]: - """Return ccache settings for PlatformIO builds. + r"""Return ccache settings for PlatformIO builds. Enabled by default whenever the ``ccache`` binary is on PATH; set ``ESPHOME_CCACHE_ENABLE=0`` in the environment to opt out (or ``1`` to - force it on). The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` - so platform build scripts (e.g. the esp8266 ``ccache.py`` extra script, - which wraps compiler invocations inside SCons) only have to check for - ``"1"`` instead of re-implementing the policy. + force it on without the runnability probe; a binary is still needed). + The decision is normalized into ``ESPHOME_CCACHE_ENABLE`` and the + binary's location into ``ESPHOME_CCACHE_PATH`` so platform build scripts + (the shared ``ccache.py`` extra script, which wraps compiler invocations + inside SCons) only have to check for ``"1"`` and use the path as given + instead of re-implementing the policy. + + The path is exported rather than looked up again inside SCons because + ``shutil.which`` can return a Windows extended-length ``\\?\`` path + (ESPHome Desktop puts its bundled ccache on PATH that way). Such a path + runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, + but SCons runs every compile through ``cmd.exe``, which fails on it with + "The system cannot find the path specified." (#18399), so the prefix is + stripped here with ``_strip_win_long_path_prefix()`` before the + runnability probe, which therefore validates the exact string the build + will execute. + ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the + script only honours it together with ``ESPHOME_CCACHE_ENABLE=1``, and this + function always sets both or neither. The returned values are merged into the environment of the PlatformIO subprocess only, never into ``os.environ``: a long-running process @@ -293,13 +308,27 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - if "ESPHOME_CCACHE_ENABLE" in os.environ: - enabled = get_bool_env("ESPHOME_CCACHE_ENABLE") - else: - enabled = _ccache_usable() - env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"} - if not enabled: - return env + explicit = "ESPHOME_CCACHE_ENABLE" in os.environ + if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): + return {"ESPHOME_CCACHE_ENABLE": "0"} + ccache_path = shutil.which("ccache") + if ccache_path is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return {"ESPHOME_CCACHE_ENABLE": "0"} + # Strip before probing so the probe validates (and the failure warning + # names) the exact string the build will execute through cmd.exe. + ccache_path = _strip_win_long_path_prefix(ccache_path) + # An explicit opt-in skips the runnability probe. + if not explicit and not _ccache_runs(ccache_path): + return {"ESPHOME_CCACHE_ENABLE": "0"} + env = { + "ESPHOME_CCACHE_ENABLE": "1", + "ESPHOME_CCACHE_PATH": ccache_path, + } # build_path is set during preload for every config-loading command, so it # being unset means a caller built the environment too early; fail loudly # rather than with an opaque TypeError from Path(None). diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index eebb0b8cd7..172b288c25 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json @@ -437,6 +437,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert env["CCACHE_DIR"].endswith("platformio-ccache") assert env["CCACHE_NOHASHDIR"] == "true" @@ -446,17 +447,35 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ -def test_ccache_env_disabled_without_binary(setup_core: Path) -> None: - """Ccache stays off when the binary is not on PATH.""" +@pytest.mark.parametrize( + ("env_vars", "expect_warning"), + [ + pytest.param({}, False, id="default"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, True, id="forced-on"), + ], +) +def test_ccache_env_disabled_without_binary( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + env_vars: dict[str, str], + expect_warning: bool, +) -> None: + """Ccache stays off when the binary is not on PATH, even when forced on. + + A deliberate opt-in that finds no binary is downgraded with a warning so + the user can tell why it had no effect; the default path stays quiet. + """ CORE.build_path = setup_core / "build" / "test" with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env_vars, clear=True), patch.object(toolchain.shutil, "which", return_value=None), + caplog.at_level("WARNING"), ): env = toolchain._ccache_env() assert env == {"ESPHOME_CCACHE_ENABLE": "0"} + assert ("no ccache binary is on PATH" in caplog.text) is expect_warning @pytest.mark.parametrize( @@ -489,14 +508,47 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), patch.object(toolchain.subprocess, "run") as mock_probe, ): env = toolchain._ccache_env() assert env["ESPHOME_CCACHE_ENABLE"] == "1" + # The binary's location is still handed to the build script. + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" mock_probe.assert_not_called() +def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: + r"""A ``\\?\`` ccache path from PATH is exported without the prefix. + + That is the shape ESPHome Desktop puts on PATH (#18399); see ``_ccache_env``. + """ + CORE.build_path = setup_core / "build" / "test" + prefixed = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder" + "\\ccache\\ccache.exe" + ) + stripped = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Device Builder\\ccache\\ccache.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=True), + # shutil.which is patched, so the win32 code path of the real + # implementation (which crashes on a POSIX host) is never reached. + patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch.object(toolchain.shutil, "which", return_value=prefixed), + patch.object(toolchain.subprocess, "run") as mock_probe, + ): + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == stripped + # The probe validates the exact string the build will execute. + assert mock_probe.call_args[0][0] == [stripped, "--version"] + + def test_ccache_env_opt_out(setup_core: Path) -> None: """ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present.""" CORE.build_path = setup_core / "build" / "test" @@ -516,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -563,8 +615,10 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( env = mock_run_external_process.call_args[1]["env"] assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == "/usr/bin/ccache" assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve()) assert "ESPHOME_CCACHE_ENABLE" not in os.environ + assert "ESPHOME_CCACHE_PATH" not in os.environ assert "CCACHE_BASEDIR" not in os.environ @@ -613,6 +667,182 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for ccache.py.""" + + def Replace(self, **kwargs: object) -> None: # noqa: N802 + self.update(kwargs) + + +def _load_ccache_script( + env_vars: dict[str, str], original_spawn: Callable[..., int] | None = None +) -> tuple[_FakeSConsEnv, Callable[..., int]]: + """Run ccache.py.script against a fake SCons env and return (env, original SPAWN).""" + if original_spawn is None: + original_spawn = Mock(name="original_spawn", return_value=0) + scons_env = _FakeSConsEnv(SPAWN=original_spawn) + source = (Path(toolchain.__file__).parent / "ccache.py.script").read_text() + with patch.dict(os.environ, env_vars, clear=True): + exec( # noqa: S102 + compile(source, "ccache.py", "exec"), + {"Import": lambda *_names: None, "env": scons_env}, + ) + return scons_env, original_spawn + + +def _scons_win32_escape(x: str) -> str: + """Copy of ``SCons.Platform.win32.escape``: quote, guarding a trailing backslash.""" + if x[-1] == "\\": + x = x + "\\" + return '"' + x + '"' + + +def test_ccache_script_wraps_compiles_with_exported_path() -> None: + """The SCons script uses ESPHOME_CCACHE_PATH as given, without a PATH lookup.""" + ccache_path = "C:\\Users\\jesse\\ESPHome Device Builder\\ccache\\ccache.exe" + scons_env, original_spawn = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": ccache_path} + ) + spawn = scons_env["SPAWN"] + assert spawn is not original_spawn + + # A compile step is routed through ccache, with the same path used for + # the program and (escaped) as the first argument. + compile_args = ["xtensa-lx106-elf-g++", "-o", "main.o", "-c", "main.cpp"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", compile_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", + _scons_win32_escape, + ccache_path, + [_scons_win32_escape(ccache_path), *compile_args], + {}, + ) + + # Link steps pass through untouched. + original_spawn.reset_mock() + link_args = ["xtensa-lx106-elf-g++", "-o", "firmware.elf", "main.o"] + spawn("cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {}) + original_spawn.assert_called_once_with( + "cmd.exe", _scons_win32_escape, "xtensa-lx106-elf-g++", link_args, {} + ) + + +@pytest.mark.parametrize( + "env_vars", + [ + pytest.param({"ESPHOME_CCACHE_ENABLE": "0"}, id="disabled"), + pytest.param({"ESPHOME_CCACHE_ENABLE": "1"}, id="enabled-without-path"), + pytest.param({}, id="unset"), + ], +) +def test_ccache_script_leaves_spawn_alone_without_path( + env_vars: dict[str, str], +) -> None: + """Without both the enable flag and a path, SPAWN is not replaced.""" + scons_env, original_spawn = _load_ccache_script(env_vars) + assert scons_env["SPAWN"] is original_spawn + + +def _scons_win32_spawn( + sh: str, escape: Callable[[str], str], cmd: str, args: list[str], env: dict +) -> int: + r"""Mirror of ``SCons.Platform.win32.spawn``: every command runs via ``cmd.exe /C``. + + SCons is not importable in the test environment (PlatformIO fetches it at + build time), so the lines that matter are mirrored here. The command line + SCons hands ``os.spawnve`` goes to ``CreateProcess`` via ``subprocess`` + instead (identical on Windows, where a string passes through untouched); + ``spawnve`` itself crashes inside pytest. + """ + return subprocess.run( + " ".join([sh, "/C", escape(" ".join(args))]), env=env, check=False + ).returncode + + +_MARKER_ENV = "ESPHOME_TEST_CCACHE_MARKER" +# Stands in for a compile: the "ccache" is really the Python interpreter, and +# the compile "flags" make it write a marker file so the test can tell whether +# the wrapped command actually ran to completion. +_FAKE_COMPILE_ARGS = [ + "-c", + f"import os, pathlib; pathlib.Path(os.environ['{_MARKER_ENV}']).write_text('compiled')", +] + + +def _spawn_fake_compile_via_cmd_exe(scons_env: _FakeSConsEnv, marker: Path) -> int: + """Run one wrapped compile step the way SCons does on Windows.""" + child_env = {**os.environ, _MARKER_ENV: str(marker)} + return scons_env["SPAWN"]( + os.environ.get("COMSPEC", "cmd.exe"), + _scons_win32_escape, + "xtensa-lx106-elf-gcc", + [_scons_win32_escape(arg) if " " in arg else arg for arg in _FAKE_COMPILE_ARGS], + child_env, + ) + + +_WINDOWS_ONLY = pytest.mark.skipif( + sys.platform != "win32", reason="drives cmd.exe, which SCons uses only on Windows" +) + + +@_WINDOWS_ONLY +def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: + r"""With a ``\\?\`` which result, the real probe runs the stripped binary. + + The probe therefore validates the exact string the build will execute + through ``cmd.exe``; probing the verbatim path instead would pass even + when the stripped path is unusable (``CreateProcess`` accepts + extended-length paths, ``cmd.exe`` does not). + """ + CORE.build_path = setup_core / "build" / "test" + assert not sys.executable.startswith("\\\\?\\") + + with ( + patch.dict(os.environ, {}, clear=False), + patch.object( + toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable + ), + ): + os.environ.pop("ESPHOME_CCACHE_ENABLE", None) + env = toolchain._ccache_env() + + assert env["ESPHOME_CCACHE_ENABLE"] == "1" + assert env["ESPHOME_CCACHE_PATH"] == sys.executable + + +@_WINDOWS_ONLY +@pytest.mark.parametrize( + ("prefix", "expect_ok"), + [ + pytest.param("", True, id="stripped-path-compiles"), + pytest.param("\\\\?\\", False, id="verbatim-path-fails"), + ], +) +def test_ccache_wrapper_through_cmd_exe( + tmp_path: Path, prefix: str, expect_ok: bool +) -> None: + r"""End to end through ``cmd.exe``: the exported path works, a ``\\?\`` one does not. + + The interpreter stands in for ccache; the spawn mirrors SCons on Windows. + The failing case is the mechanism behind #18399 ("The system cannot find + the path specified." on every compile step); should it ever start passing, + ``cmd.exe`` learned extended-length paths and the strip is no longer needed. + """ + marker = tmp_path / "compiled.txt" + scons_env, _ = _load_ccache_script( + {"ESPHOME_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_PATH": prefix + sys.executable}, + original_spawn=_scons_win32_spawn, + ) + assert scons_env["SPAWN"] is not _scons_win32_spawn + + rc = _spawn_fake_compile_via_cmd_exe(scons_env, marker) + assert (rc == 0) is expect_ok + assert marker.exists() is expect_ok + if expect_ok: + assert marker.read_text() == "compiled" + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ From 6084314cc9c029b4b6b131a92665d98d4046e464 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:37:10 -0500 Subject: [PATCH 276/597] [vscode] Report the origin of an unexpected exception during validation (#18494) --- esphome/vscode.py | 20 +++++++++- tests/unit_tests/test_vscode.py | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/esphome/vscode.py b/esphome/vscode.py index f404f02f00..ba7b4e727b 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -3,12 +3,14 @@ from __future__ import annotations from io import StringIO import json from pathlib import Path +import sys +import traceback from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION -from esphome.core import CORE, DocumentRange +from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml @@ -97,6 +99,16 @@ def _ace_loader(fname: Path) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream) +def _format_unexpected_error(err: Exception) -> str: + """Describe a crash inside validation with the frame it came from.""" + message = f"Unexpected error while validating: {type(err).__name__}: {err}" + frames = traceback.extract_tb(err.__traceback__) + if not frames: + return message + frame = frames[-1] + return f"{message} ({frame.filename}:{frame.lineno} in {frame.name})" + + def _print_version(): """Print ESPHome version.""" print( @@ -134,8 +146,12 @@ def read_config(args): try: config = loader(file_name) res = validate_config(config, command_line_substitutions) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + except (EsphomeError, cv.Invalid) as err: vs.add_yaml_error(str(err)) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # stdout carries the JSON protocol; the full chain goes to stderr. + traceback.print_exc(file=sys.stderr) + vs.add_yaml_error(_format_unexpected_error(err)) else: for err in res.errors: try: diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 63bdf3e255..9b7d1e9504 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode +import esphome.config_validation as cv +from esphome.core import EsphomeError def _run_repl_test(input_data): @@ -126,3 +128,67 @@ packages: assert range["start_col"] == 2 assert range["end_line"] == 1 assert range["end_col"] == 7 + + +def _explode(*_args: object, **_kwargs: object) -> None: + raise AttributeError("'NoneType' object has no attribute 'get'") + + +def test_unexpected_error_reports_origin() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", _explode): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["validation_errors"] == [] + (error,) = result["yaml_errors"] + assert error["message"].startswith( + "Unexpected error while validating: AttributeError: " + "'NoneType' object has no attribute 'get' (" + ) + assert "test_vscode.py" in error["message"] + assert error["message"].endswith(" in _explode)") + + +def test_esphome_error_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=EsphomeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "boom"}] + + +def test_invalid_stays_plain() -> None: + source_path = str(Path("dir_path", "x.yaml")) + with patch("esphome.vscode.validate_config", side_effect=cv.Invalid("bad value")): + output_lines = _run_repl_test( + [ + _validate(source_path), + _file_response("""esphome: + name: test1 +"""), + ] + ) + + result = json.loads(output_lines[-1]) + assert result["yaml_errors"] == [{"message": "bad value"}] + + +def test_format_unexpected_error_without_traceback() -> None: + message = vscode._format_unexpected_error(ValueError("boom")) + assert message == "Unexpected error while validating: ValueError: boom" From b768e2a1ce796f7055f9fcba8e8b3494798ce8fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:09 -0400 Subject: [PATCH 277/597] [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) --- esphome/components/esp32/__init__.py | 78 +++++++++++++++------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ada6d25db5..2c06ebac9a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1070,6 +1070,26 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) @@ -1082,6 +1102,8 @@ def _detect_variant(value): # name rather than carrying a PIO board name through the IDF build. if CORE.using_toolchain_esp_idf: value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() return value if variant not in STANDARD_BOARDS: @@ -1092,22 +1114,8 @@ def _detect_variant(value): ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -1117,6 +1125,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -1128,6 +1144,9 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value @@ -1431,20 +1450,6 @@ def final_validate(config): path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -2517,15 +2522,14 @@ async def to_code(config): f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True ) - # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 - # from y to n. PlatformIO uses sections.ld.in (for rev <3) or - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, From 7418fcce8d8f154bceb088f1ad10782c6dadb4ca Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:38:18 -0400 Subject: [PATCH 278/597] [ci] Stop persisting the integration test ccache (#18504) --- .github/workflows/ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2075fde9ef..0d8f35ed83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,18 +341,6 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends ccache - - name: Restore ccache (restore-only) - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). The - # bucket-name prefix prefers a same-bucket seed; the bare prefix falls - # back to any seed when the bucket layout differs from dev. - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} - restore-keys: | - integration-ccache-${{ matrix.bucket.name }}- - integration-ccache- - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -401,14 +389,6 @@ jobs: # esphome stores the PlatformIO ccache under the machine-global cache # dir (see _ccache_env() in esphome/platformio/toolchain.py). run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save ccache - # Pull request saves land in per-PR scopes nothing else can reuse; - # dev pushes seed the shared copy instead. - if: github.event_name != 'pull_request' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/esphome/platformio-ccache - key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} import-time: name: Check import esphome.__main__ time From e9e77d02a00d6d9b8f0661b0e4c4a025b4f697b9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:57:51 -0500 Subject: [PATCH 279/597] Bump bundled esphome-device-builder to 1.11.3 (#18505) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a8daeaaf6..50b698224c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 RUN \ platformio settings set enable_telemetry No \ From 74e22b5ad74308fbed86738bf63fbcaed9f0fd03 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:23 -0500 Subject: [PATCH 280/597] Bump bundled esphome-device-builder to 1.11.4 (#18506) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50b698224c..5c21e07618 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 RUN \ platformio settings set enable_telemetry No \ From 2c92a2498e5fb5632554f485eedd3446155d7e83 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 -0500 Subject: [PATCH 281/597] Bump bundled esphome-device-builder to 1.11.5 (#18507) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5c21e07618..1be10db3af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 RUN \ platformio settings set enable_telemetry No \ From 4a85c98285c1a2c38b2e5e9115fb4793b5b1d69f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:21:58 -0500 Subject: [PATCH 282/597] Bump bundled esphome-device-builder to 1.12.0 (#18514) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1be10db3af..18f705b501 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.11.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 RUN \ platformio settings set enable_telemetry No \ From b3fda9973ebd67fcafb26b4f3b7a831427ecafff Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:12 -0700 Subject: [PATCH 283/597] [image] Restore defaults:/files: support for platform entries (#18032) Co-authored-by: Claude Sonnet 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 3 +- esphome/components/image/__init__.py | 152 +++++++- esphome/components/runtime_image/__init__.py | 5 +- esphome/config.py | 17 + esphome/loader.py | 8 + tests/component_tests/image/test_init.py | 328 +++++++++++++++++- .../validate-platform-defaults.host.yaml | 21 ++ .../validate-platform-defaults.host.yaml | 24 ++ tests/unit_tests/test_config_normalization.py | 124 ++++++- 9 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 tests/components/animation/validate-platform-defaults.host.yaml create mode 100644 tests/components/image/validate-platform-defaults.host.yaml diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index b54c3f2adf..212c778763 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -23,6 +23,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -200,7 +201,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84d..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: return get_all_image_metadata().get(image_id) +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + # --------------------------------------------------------------------------- # Legacy top-level component -> `image:` platform deprecation helpers # -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. return bool(config) and all( - isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. return False # A single image dict, or the grouped `defaults:`/`images:`/type-key form. return ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(merged, {})) def _add_entries(entries: object, extra: dict) -> None: # `entries` may be a single image dict or a list of them; non-dict diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index d8517d4493..9fa32a5a65 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER from esphome.components.image import ( IMAGE_TYPE, Image_, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True), cv.Optional(CONF_RESIZE): cv.dimensions, cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "BIG_ENDIAN", "LITTLE_ENDIAN", upper=True - ), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(), cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_), } diff --git a/esphome/config.py b/esphome/config.py index 987bb9c96a..13ec744ce4 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -620,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep): elif not isinstance(self.conf, list): result[self.domain] = self.conf = [self.conf] + # Permanent expansion hook: a platform-tagged entry may expand into + # several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only. + if (expand := component.expand_platform_config) is not None and all( + isinstance(entry, dict) and CONF_PLATFORM in entry + for entry in self.conf + ): + with result.catch_error(path): + expanded = expand(self.conf) + if not isinstance(expanded, list): + # A non-list return is a component bug (not a user error): + # raise explicitly (survives -O/-OO) so it escapes catch_error. + raise TypeError( + f"{self.domain}: EXPAND_PLATFORM_CONFIG must " + f"return a list, got {type(expanded).__name__}" + ) + result[self.domain] = self.conf = expanded + # Process AUTO_LOAD _process_auto_load(result, component, path) diff --git a/esphome/loader.py b/esphome/loader.py index f994f0c5eb..23c6d1bfa5 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -164,6 +164,14 @@ class ComponentManifest: """ return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property + def expand_platform_config( + self, + ) -> Callable[[list[ConfigType]], list[ConfigType]] | None: + """Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged + entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors).""" + return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 78462463b1..846c152cab 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -21,16 +21,20 @@ from esphome.components.image import ( CONF_OPAQUE, CONF_TRANSPARENCY, PLATFORM_FILE, + _expand_platform_entry, _flatten_legacy_image_config, _is_legacy_image_format, _is_new_image_format, _migrate_legacy_image_config, + expand_platform_config, get_all_image_metadata, get_image_metadata, ) from esphome.const import ( + CONF_DEFAULTS, CONF_DITHER, CONF_FILE, + CONF_FILES, CONF_ID, CONF_PLATFORM, CONF_RAW_DATA_ID, @@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None: assert out[0][CONF_BYTE_ORDER] == "little_endian" +def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None: + """The legacy flattener drops an incompatible byte_order even when written directly on the entry.""" + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + def test_flatten_skips_meta_and_unknown_keys() -> None: out = _flatten_legacy_image_config( { @@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform( ), pytest.param({"foo": 1}, False, id="dict_unknown_keys"), pytest.param("a string", False, id="scalar"), + # A `platform:`-tagged dict is the new format written without list brackets. + pytest.param( + {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}, + False, + id="platform_tagged_flat_dict", + ), + pytest.param( + { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="platform_tagged_defaults_files_dict", + ), + # `files:` without `platform:` is not legacy either -- the flattener has no branch for it. + pytest.param( + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + }, + False, + id="defaults_files_dict_without_platform", + ), + # Same as above in a list -- without this exclusion it would be silently + # migrated to a hard-coded `platform: file` instead of raising the error. + pytest.param( + [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "x.png"}], + } + ], + False, + id="defaults_files_list_entry_without_platform", + ), ], ) def test_is_legacy_image_format(config: object, expected: bool) -> None: @@ -359,17 +408,290 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None: def test_migrate_returns_none_for_invalid_legacy_shapes( config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Unrecognised shapes are not migrated (and emit no warning) so normal - platform validation surfaces a proper error instead of silently dropping - the offending input.""" + """Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them.""" with caplog.at_level(logging.WARNING): assert _migrate_legacy_image_config(config) is None assert "deprecated" not in caplog.text +def test_migrate_returns_none_for_mapping_form_defaults_files() -> None: + """A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator.""" + config = { + CONF_PLATFORM: "file", + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None: + """`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has + no `files:` branch and would silently return `[]`.""" + config = { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + assert _migrate_legacy_image_config(config) is None + + +def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None: + """Same, in a list -- previously the list branch migrated it to a hard-coded + `platform: file` instead of raising a missing-platform error.""" + config = [ + { + "defaults": {"type": "rgb565"}, + "files": [{"id": "a", "file": "a.png"}], + } + ] + assert _migrate_legacy_image_config(config) is None + + # --------------------------- end legacy migration -------------------------- +def test_expand_platform_entry_passes_through_plain_entry() -> None: + entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"} + assert _expand_platform_entry(0, entry) == [entry] + + +def test_expand_platform_entry_expands_files_with_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png", "type": "GRAYSCALE"}, + ], + } + assert _expand_platform_entry(0, entry) == [ + { + CONF_PLATFORM: "file", + "id": "img1", + "file": "foo.png", + "type": "RGB565", + "transparency": "opaque", + }, + { + CONF_PLATFORM: "file", + "id": "img2", + "file": "bar.png", + "type": "GRAYSCALE", + "transparency": "opaque", + }, + ] + + +def test_expand_platform_entry_files_without_defaults() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + assert _expand_platform_entry(0, entry) == [ + {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + ] + + +def test_expand_platform_entry_preserves_source_range() -> None: + """A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there.""" + from esphome import yaml_util + + file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"}) + file_entry._esp_range = "sentinel-range" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [file_entry], + } + [out] = _expand_platform_entry(0, entry) + assert isinstance(out, yaml_util.ESPHomeDataBase) + assert out.esp_range == "sentinel-range" + + +def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None: + """Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "img1", "file": "foo.png"}], + } + [out] = _expand_platform_entry(0, entry) + assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"} + + +def test_expand_platform_entry_per_file_overrides_win() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["type"] == "BINARY" + + +def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None: + """A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"}, + CONF_FILES: [ + {"id": "a", "file": "x.png"}, + {"id": "b", "file": "y.png", "type": "binary"}, + ], + } + out = _expand_platform_entry(0, entry) + assert out[0]["byte_order"] == "little_endian" + assert "byte_order" not in out[1] + + +def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None: + """A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}], + } + with pytest.raises(cv.Invalid, match="did you mean") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"}, + CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "big_endian" + + +def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None: + """A `byte_order` written directly on the entry is kept so validate_settings raises the normal error.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "rgb565"}, + CONF_FILES: [ + { + "id": "a", + "file": "x.png", + "type": "binary", + "byte_order": "little_endian", + } + ], + } + [out] = _expand_platform_entry(0, entry) + assert out["byte_order"] == "little_endian" + + +def test_expand_platform_entry_defaults_without_files_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}} + with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo: + _expand_platform_entry(0, entry) + assert excinfo.value.path == [0] + + +def test_expand_platform_entry_null_files_raises_not_empty() -> None: + """A `files:` key with no value parses to `None` and must be reported clearly.""" + entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None: + """An explicit `files: []` must not silently drop the whole platform entry.""" + entry = {CONF_PLATFORM: "file", CONF_FILES: []} + with pytest.raises(cv.Invalid, match="must not be empty"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_with_stray_key_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png"}], + "extra": 1, + } + with pytest.raises(cv.Invalid, match="cannot be combined with"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_id_in_defaults_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_ID: "a"}, + CONF_FILES: [{"file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_defaults_raises() -> None: + """`platform:` inside `defaults:` would silently reassign every file's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {CONF_PLATFORM: "animation"}, + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_platform_in_file_entry_raises() -> None: + """`platform:` on a `files:` item must not silently override the entry's platform.""" + entry = { + CONF_PLATFORM: "file", + CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}], + } + with pytest.raises(cv.Invalid, match="not allowed inside"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_files_not_list_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"} + with pytest.raises(cv.Invalid, match="must be a list"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_defaults_not_mapping_raises() -> None: + entry = { + CONF_PLATFORM: "file", + CONF_DEFAULTS: "not-a-mapping", + CONF_FILES: [{"id": "a", "file": "x.png"}], + } + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_entry_file_item_not_mapping_raises() -> None: + entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]} + with pytest.raises(cv.Invalid, match="must be a mapping"): + _expand_platform_entry(0, entry) + + +def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None: + config = [ + { + CONF_PLATFORM: "file", + CONF_DEFAULTS: {"type": "RGB565"}, + CONF_FILES: [ + {"id": "img1", "file": "foo.png"}, + {"id": "img2", "file": "bar.png"}, + ], + }, + {CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"}, + ] + out = expand_platform_config(config) + assert [entry["id"] for entry in out] == ["img1", "img2", "plain"] + + +def test_expand_platform_config_ignores_non_platform_entries() -> None: + # Not expanded here -- legacy_config_migrate runs before this hook and is + # responsible for tagging/flattening pre-platform shapes. + config = ["not-a-platform-entry"] + assert expand_platform_config(config) == config + + +# --------------------- end defaults/files expansion ------------------------- + + def test_validate_image_final_defaults_to_little_endian() -> None: out = validate_image_final({CONF_FILE: "x.png"}) assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" diff --git a/tests/components/animation/validate-platform-defaults.host.yaml b/tests/components/animation/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..034497c548 --- /dev/null +++ b/tests/components/animation/validate-platform-defaults.host.yaml @@ -0,0 +1,21 @@ +# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: animation + defaults: + type: rgb565 + transparency: opaque + resize: 50x50 + files: + - id: platform_defaults_animation + file: $component_dir/anim.gif + - id: platform_defaults_animation_rgb + file: $component_dir/anim.apng + type: rgb diff --git a/tests/components/image/validate-platform-defaults.host.yaml b/tests/components/image/validate-platform-defaults.host.yaml new file mode 100644 index 0000000000..e1b3037cc3 --- /dev/null +++ b/tests/components/image/validate-platform-defaults.host.yaml @@ -0,0 +1,24 @@ +# `platform: file` entry using the `defaults:`/`files:` shape, including the +# per-type byte_order drop when an entry overrides to a non-endian type. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - platform: file + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + files: + - id: platform_defaults_image + file: ../../pnglogo.png + - id: platform_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index c8b7b63094..04363ad45b 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from esphome import config, yaml_util +from esphome import config, config_validation as cv, yaml_util from esphome.core import CORE, AutoLoad from esphome.types import ConfigType @@ -127,12 +127,14 @@ def _run_load_step( domain: str, conf: object, migrate: Callable[[ConfigType], list | None] | None, + expand: Callable[[list], list] | None = None, ) -> config.Config: - """Run a LoadValidationStep for a platform component with a given migrate hook.""" + """Run a LoadValidationStep for a platform component with given hooks.""" component = Mock() component.is_platform_component = True component.multi_conf_no_default = False component.legacy_config_migrate = migrate + component.expand_platform_config = expand result = config.Config() with ( @@ -197,6 +199,124 @@ def test_legacy_migrate_skipped_for_autoload() -> None: assert result["image"] == [auto] +# --------------------------------------------------------------------------- +# EXPAND_PLATFORM_CONFIG hook on LoadValidationStep -- permanent counterpart +# to legacy_config_migrate; runs after legacy migration/list normalization. +# --------------------------------------------------------------------------- + + +def test_expand_hook_rewrites_conf() -> None: + """A config the expand hook rewrites is replaced with the expanded list.""" + expanded = [{"platform": "file", "id": "a"}, {"platform": "file", "id": "b"}] + expand = Mock(return_value=expanded) + + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + expand.assert_called_once_with([{"platform": "file", "id": "a"}]) + assert result["image"] == expanded + + +def test_expand_hook_absent_is_noop() -> None: + """A platform component without the hook is left as normalized by the + existing list-wrapping logic.""" + result = _run_load_step("image", [{"platform": "file", "id": "a"}], None, None) + + assert result["image"] == [{"platform": "file", "id": "a"}] + + +def test_expand_hook_runs_after_legacy_migrate() -> None: + """The expand hook sees the already-migrated list, not the raw legacy conf.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + expand = Mock(side_effect=lambda conf: conf) + + _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate, expand) + + expand.assert_called_once_with(migrated) + + +def test_expand_hook_skipped_for_non_dict_entry() -> None: + """Malformed entries are left alone; the hook only sees `platform:`-tagged dicts.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", ["not-a-dict"], None, expand) + + expand.assert_not_called() + assert result["image"] == ["not-a-dict"] + + +def test_expand_hook_skipped_for_entry_missing_platform_key() -> None: + """A dict entry missing the `platform:` key is left alone -- the normal + per-entry error reporting further down catches this case instead.""" + expand = Mock(side_effect=lambda conf: conf) + + result = _run_load_step("image", [{"id": "a"}], None, expand) + + expand.assert_not_called() + assert result["image"] == [{"id": "a"}] + + +def test_expand_hook_skipped_for_autoload() -> None: + """A non-empty AutoLoad reaching the hook stage is left alone.""" + expand = Mock(side_effect=lambda conf: conf) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, None, expand) + + expand.assert_not_called() + assert result["image"] == [auto] + + +def test_expand_hook_runs_when_all_entries_are_platform_tagged_dicts() -> None: + """The guard does not block the normal, well-formed case.""" + expand = Mock(side_effect=lambda conf: conf) + conf = [{"platform": "file", "id": "a"}, {"platform": "animation", "id": "b"}] + + result = _run_load_step("image", conf, None, expand) + + expand.assert_called_once_with(conf) + assert result["image"] == conf + + +def test_expand_hook_invalid_reports_single_error_at_domain_path() -> None: + """A `cv.Invalid` from the hook is reported once with the domain path prepended; no further validation runs.""" + expand = Mock(side_effect=cv.Invalid("bad shape")) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0].path == ["image"] + assert "bad shape" in str(result.errors[0]) + assert result["image"] == pre_expand_conf + + +def test_expand_hook_final_external_invalid_reports_without_path_prepend() -> None: + """`cv.FinalExternalInvalid` keeps its already-resolved path (no domain path prepended).""" + already_resolved_error = cv.FinalExternalInvalid( + "bad shape", path=["image", 3, "files"] + ) + expand = Mock(side_effect=already_resolved_error) + pre_expand_conf = [{"platform": "file", "id": "a"}] + + result = _run_load_step("image", pre_expand_conf, None, expand) + + assert len(result.errors) == 1 + assert result.errors[0] is already_resolved_error + assert result.errors[0].path == ["image", 3, "files"] + assert result["image"] == pre_expand_conf + + +def test_expand_hook_non_list_return_raises_type_error() -> None: + """A non-list return is a component bug: it escapes as an uncaught TypeError + (explicit raise survives -O/-OO).""" + expand = Mock(return_value={"not": "a list"}) + + with pytest.raises(TypeError, match="must return a list"): + _run_load_step("image", [{"platform": "file", "id": "a"}], None, expand) + + def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: """Create a config where two `<<` includes both define `logger:`. From 78a65eabdc6f33e6ac7f398a905217f61f779b64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 16:30:34 -0500 Subject: [PATCH 284/597] [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) --- .github/workflows/ci-api-proto.yml | 28 +++++++++- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 820081cc46..771b4cd94f 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -41,10 +41,32 @@ jobs: version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d8f35ed83..d2d4c7a2a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + determine-jobs: name: Determine which jobs to run runs-on: ubuntu-24.04 @@ -323,7 +339,8 @@ jobs: integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # Must match seed-apt-cache's image: the apt cache key has no OS in it. + runs-on: ubuntu-24.04 needs: - common - determine-jobs @@ -335,12 +352,16 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install ccache - # Speeds up the host compiles: tests in a bucket compile overlapping - # component sets, so later tests reuse earlier tests' objects. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ccache + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -421,6 +442,7 @@ jobs: benchmarks: name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs @@ -457,6 +479,41 @@ jobs: fi echo "binary=$BINARY" >> $GITHUB_OUTPUT + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true + run: | + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + - name: Run CodSpeed benchmarks uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: @@ -875,12 +932,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1415,6 +1477,7 @@ jobs: # this check. needs: - common + - seed-apt-cache - determine-jobs - ci-custom - pylint From f735dcadc0c38f25eec83cf4f5eba97bc62e30d6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:33:05 +1200 Subject: [PATCH 285/597] Bump version to 2026.8.0b6 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 3dad4629be..c83d95d0ef 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b5 +PROJECT_NUMBER = 2026.8.0b6 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index e86465f9a0..2296f8c0b7 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b5" +__version__ = "2026.8.0b6" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From c45599196235345e2f13415eccb537b2d6e13b49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 11:39:01 -0500 Subject: [PATCH 286/597] [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2d4c7a2a1..fa119fb6d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -598,24 +598,29 @@ jobs: fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf From 185f12266a3f9ae0248df1a38ce96f2cc6aae7b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 287/597] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From d9359a70c1ef82ab907aba6adad45a27cd5d5fab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 17:29:10 -0500 Subject: [PATCH 288/597] [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) --- tests/integration/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1bf799b658..483d5392af 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -60,7 +60,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env = os.environ.copy() env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # libdeps is keyed only by env name (the device name), and fixtures share + # names; two xdist workers first-compiling the same name race pio pkg + # install in the same directory. Keep libdeps per worker. + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Compile with THIS tree's esphome sources, not wherever the venv's editable From 828eac90f36ffe9dd1fa714f41b27377fda2cafd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:51:59 +1200 Subject: [PATCH 289/597] Bump version to 2026.8.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index c83d95d0ef..ed0670621d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0b6 +PROJECT_NUMBER = 2026.8.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 2296f8c0b7..17ff1e17d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0b6" +__version__ = "2026.8.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ca97c86d6580746e12e071351bf3d219ef7de51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 19:06:55 -0500 Subject: [PATCH 290/597] [ci] Install requirements_dev.txt when the venv cache misses (#18502) --- .github/actions/restore-python/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index daf041819c..fab6dc6ffb 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . From b7d0b676fc0cd5f93a772f6f11d398a157ae612d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20H=C3=A4ll?= Date: Thu, 20 Aug 2026 06:31:27 +0200 Subject: [PATCH 291/597] [wifi] Take the lwIP core lock around sntp_servermode_dhcp() (#18511) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 245390b097..24cb060edb 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -580,7 +580,14 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - sntp_servermode_dhcp(false); + { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + // sntp_servermode_dhcp() is an empty macro unless lwIP is built with + // DHCP-supplied NTP servers, so only that build needs the core lock. + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); + } // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { From 5e9de7c94bde17b782e643a1d12745f67e23f3d6 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:00:44 -0500 Subject: [PATCH 292/597] Bump bundled esphome-device-builder to 1.12.1 (#18541) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 18f705b501..55aa0ac982 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 RUN \ platformio settings set enable_telemetry No \ From 132f494195869750e7ccf3ad2a66f58c0234da21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 06:59:57 -0500 Subject: [PATCH 293/597] [nrf52] Rebuild the Python env when its interpreter symlink dangles (#18540) --- esphome/components/nrf52/framework.py | 26 ++++--- tests/unit_tests/test_nrf52_framework.py | 90 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 6b32fe1fea..d487820440 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -62,6 +62,22 @@ def get_sdk_nrf_tools_path() -> Path: return path.resolve() +def _needs_venv_rebuild( + env_python_path: Path, sentinel: Path, requirements_hash: str +) -> bool: + """True when a penv must be (re)built. + + Rebuild when the interpreter is not a regular file, which covers a + dangling symlink (a cached venv outliving a host interpreter upgrade) + and a corrupt restore, or when the sentinel is missing or stale. + """ + return ( + not env_python_path.is_file() + or not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ) + + def _get_python_env_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "penvs" / version @@ -198,10 +214,7 @@ def setup_platformio_python_env() -> None: + "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode() + f"python{sys.version_info.major}.{sys.version_info.minor}".encode() ).hexdigest() - if ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ): + if _needs_venv_rebuild(env_python_path, sentinel, requirements_hash): rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment") create_venv(penv_path, msg="PlatformIO toolchain") @@ -250,10 +263,7 @@ def check_and_install() -> None: env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() - install_venv = ( - not sentinel.exists() - or sentinel.read_text(encoding="utf-8") != requirements_hash - ) + install_venv = _needs_venv_rebuild(env_python_path, sentinel, requirements_hash) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 0a6bddc280..c2ee0c2a75 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -16,6 +16,7 @@ from esphome.components.nrf52.framework import ( _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, + _needs_venv_rebuild, check_and_install, get_build_env, get_sdk_nrf_tools_path, @@ -123,10 +124,19 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _touch_penv_python(penv: Path) -> None: + """Create the interpreter file so the rebuild gate sees a live venv.""" + python = get_python_env_executable_path(penv, "python") + python.parent.mkdir(parents=True, exist_ok=True) + python.touch() + + def _mark_venv_ready(python_env: Path) -> None: - """Write the venv sentinel with the current requirements hash.""" + """Write the venv sentinel with the current requirements hash and a + present interpreter so the rebuild gate passes.""" requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + _touch_penv_python(python_env) class TestCheckAndInstall: @@ -148,6 +158,23 @@ class TestCheckAndInstall: mock_nrf52_ops.download_from_mirrors.assert_not_called() mock_nrf52_ops.archive_extract_all.assert_not_called() + def test_missing_interpreter_rebuilds_venv( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter (a cached venv + restored after a host interpreter upgrade).""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (nrf52_dirs.python_env / ".ready").write_text( + requirements_hash, encoding="utf-8" + ) + # no interpreter on disk + + check_and_install() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_fresh_install_runs_all_steps( self, nrf52_dirs: SimpleNamespace, @@ -348,6 +375,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) with patch.dict(os.environ): setup_platformio_python_env() @@ -392,6 +420,22 @@ class TestSetupPlatformioPythonEnv: assert not (platformio_penv_dir / ".ready").exists() + def test_missing_interpreter_reinstalls( + self, + platformio_penv_dir: Path, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A valid sentinel must not mask a missing interpreter.""" + (platformio_penv_dir / ".ready").write_text( + _platformio_requirements_hash(), encoding="utf-8" + ) + # no interpreter on disk + + with patch.dict(os.environ): + setup_platformio_python_env() + + mock_nrf52_ops.create_venv.assert_called_once() + def test_repeated_calls_do_not_duplicate_env_entries( self, platformio_penv_dir: Path, @@ -401,6 +445,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) bin_dir = str( get_python_env_executable_path(platformio_penv_dir, "python").parent @@ -422,6 +467,7 @@ class TestSetupPlatformioPythonEnv: (platformio_penv_dir / ".ready").write_text( _platformio_requirements_hash(), encoding="utf-8" ) + _touch_penv_python(platformio_penv_dir) site_packages = str(_get_penv_site_packages(platformio_penv_dir)) with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}): @@ -531,3 +577,45 @@ def testget_tools_path_default_is_global_cache( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" ).resolve() assert get_sdk_nrf_tools_path() == expected + + +def test_needs_venv_rebuild_gates(tmp_path: Path) -> None: + """The shared penv gate rebuilds on any missing or stale piece.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + good_hash = "abc123" + + # Nothing in place yet + assert _needs_venv_rebuild(python, sentinel, good_hash) + + python.write_text("") + # Interpreter present but no sentinel + assert _needs_venv_rebuild(python, sentinel, good_hash) + + sentinel.write_text(good_hash, encoding="utf-8") + # Everything in place + assert not _needs_venv_rebuild(python, sentinel, good_hash) + + # Stale requirements hash + assert _needs_venv_rebuild(python, sentinel, "otherhash") + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlink creation needs privileges on Windows" +) +def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> None: + """A cached venv restored after a host interpreter upgrade has a + bin/python symlink whose target is gone; the valid sentinel must not + mask it.""" + penv = tmp_path / "penv" + penv.mkdir() + python = penv / "python" + sentinel = penv / ".ready" + sentinel.write_text("abc123", encoding="utf-8") + python.symlink_to(tmp_path / "hostedtoolcache" / "3.12.14" / "python3") + assert python.is_symlink() + assert not python.exists() + + assert _needs_venv_rebuild(python, sentinel, "abc123") From aafeca585920d39990457e46fec68faf8d4ae2d8 Mon Sep 17 00:00:00 2001 From: Alar Aun Date: Thu, 20 Aug 2026 16:54:28 +0300 Subject: [PATCH 294/597] [modbus_controller] Brace single-statement log bodies to fix -Wempty-body (#18543) --- esphome/components/modbus_controller/modbus_controller.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 2c568938e4..515459f62a 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -200,8 +200,9 @@ void ModbusController::update_range_(ModbusCommandItem &cmd) { return; } // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address()); + } } void ModbusController::update() { @@ -214,8 +215,9 @@ void ModbusController::update() { ESP_LOGV(TAG, "Module offline - retrying"); this->cmd_non_responses_ = 0; // allow the probe through can_send() for (auto &cmd : this->polling_command_items_) { - if (!cmd.send()) + if (!cmd.send()) { ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address()); + } } } else { ESP_LOGV(TAG, "Module offline - skipping update"); From 3c47ab42d63b53026dcfa611baca05d08b79e3ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:55:15 +1200 Subject: [PATCH 295/597] [core] Add type annotations to component Python (1/11) (#18338) --- esphome/components/bl0906/sensor.py | 12 +++++-- esphome/components/datetime/__init__.py | 36 +++++++++++++------ esphome/components/esp32_ble/__init__.py | 30 ++++++++++++---- esphome/components/esp32_rmt/__init__.py | 14 +++++--- esphome/components/espnow/__init__.py | 27 ++++++++------ .../espnow/packet_transport/__init__.py | 3 +- esphome/components/http_request/__init__.py | 20 +++++++---- .../components/http_request/ota/__init__.py | 13 +++++-- .../http_request/update/__init__.py | 3 +- esphome/components/i2s_audio/__init__.py | 13 +++---- .../i2s_audio/microphone/__init__.py | 13 +++---- .../components/i2s_audio/speaker/__init__.py | 13 +++---- esphome/components/mcp23xxx_base/__init__.py | 8 +++-- esphome/components/mcp4461/__init__.py | 3 +- esphome/components/mcp4461/output/__init__.py | 28 ++++++++++++--- esphome/components/microphone/__init__.py | 31 ++++++++++------ esphome/components/pn532/__init__.py | 14 ++++++-- esphome/components/pn532/binary_sensor.py | 7 ++-- esphome/components/pn7150/__init__.py | 26 +++++++++++--- esphome/components/pn7160/__init__.py | 26 +++++++++++--- 20 files changed, 245 insertions(+), 95 deletions(-) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 059e10e962..1a0c2287ab 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,6 +32,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py from .const import ICON_ENERGY, ICON_FREQUENCY, ICON_VOLTAGE @@ -145,13 +148,18 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 87997daa3d..f8b6446006 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -21,13 +21,14 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -65,7 +66,7 @@ DATETIME_MODES = [ ] -def _validate_time_present(config): +def _validate_time_present(config: ConfigType) -> ConfigType: config = config.copy() if CONF_ON_TIME in config and CONF_TIME_ID not in config: time_id = cv.use_id(time.RealTimeClock)(None) @@ -139,7 +140,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: @setup_entity("datetime") -async def setup_datetime_core_(var, config): +async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) @@ -160,7 +161,7 @@ async def setup_datetime_core_(var, config): await cg.register_parented(trigger, var) -async def register_datetime(var, config): +async def register_datetime(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() @@ -169,14 +170,14 @@ async def register_datetime(var, config): await setup_datetime_core_(var, config) -async def new_datetime(config, *args): +async def new_datetime(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_datetime(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) @@ -193,7 +194,12 @@ async def to_code(config): ), synchronous=True, ) -async def datetime_date_set_to_code(config, action_id, template_arg, args): +async def datetime_date_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -226,7 +232,12 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_time_set_to_code(config, action_id, template_arg, args): +async def datetime_time_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -259,7 +270,12 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_datetime_set_to_code(config, action_id, template_arg, args): +async def datetime_datetime_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f099c68e57..79747c6f31 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -31,7 +31,8 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, ID, TimePeriod +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -383,7 +384,7 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -def validate_variant(_): +def validate_variant(_: ConfigType) -> None: variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: raise cv.Invalid(f"{variant} does not support Bluetooth") @@ -443,7 +444,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config) -> None: +def final_validation(config: ConfigType) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -518,7 +519,7 @@ def final_validation(config) -> None: FINAL_VALIDATE_SCHEMA = final_validation -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) @@ -605,19 +606,34 @@ async def to_code(config): @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code(config, condition_id, template_arg, args): +async def ble_enabled_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(condition_id, template_arg) @automation.register_action( "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True ) -async def ble_enable_to_code(config, action_id, template_arg, args): +async def ble_enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) @automation.register_action( "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True ) -async def ble_disable_to_code(config, action_id, template_arg, args): +async def ble_disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1076bcabdc..a213a78778 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -1,17 +1,23 @@ +from collections.abc import Callable, Iterable +from typing import Any + from esphome.components import esp32 import esphome.config_validation as cv from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} -def validate_rmt_not_supported(rmt_only_keys): +def validate_rmt_not_supported( + rmt_only_keys: Iterable[str], +) -> Callable[[ConfigType], ConfigType]: """Validate that RMT-only config keys are not used on variants without RMT hardware.""" rmt_only_keys = set(rmt_only_keys) - def _validator(config): + def _validator(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in VARIANTS_NO_RMT: @@ -26,8 +32,8 @@ def validate_rmt_not_supported(rmt_only_keys): return _validator -def validate_clock_resolution(): - def _validator(value): +def validate_clock_resolution() -> Callable[[Any], int]: + def _validator(value: Any) -> int: cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 373ef345d1..ee3732c406 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi @@ -14,6 +16,7 @@ from esphome.const import ( CONF_WIFI, ) from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -78,7 +81,7 @@ CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -def _validate_max_payload_size(value: int) -> int: +def _validate_max_payload_size(value: Any) -> int: if value > ESPNOW_PAYLOAD_V1: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 0), @@ -88,7 +91,7 @@ def _validate_max_payload_size(value: int) -> int: return value -def validate_channel(value): +def validate_channel(value: Any) -> int: if value is None: raise cv.Invalid("channel is required if wifi is not configured") return wifi.validate_channel(value) @@ -129,7 +132,7 @@ CONFIG_SCHEMA = cv.All( ) -async def _trigger_to_code(config): +async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) @@ -145,7 +148,7 @@ async def _trigger_to_code(config): return trigger -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -180,13 +183,13 @@ async def to_code(config): # ========================================== A C T I O N S ================================================ -def validate_peer(value): +def validate_peer(value: Any) -> Any: if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return cv.mac_address(value) -def _validate_raw_data(value): +def _validate_raw_data(value: Any) -> str | list: if isinstance(value, str): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( @@ -204,7 +207,9 @@ def _validate_raw_data(value): ) -async def register_peer(var, config, args): +async def register_peer( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): peer = [HexInt(p) for p in peer.parts] @@ -231,7 +236,7 @@ SEND_SCHEMA = PEER_SCHEMA.extend( ) -def _validate_send_action(config): +def _validate_send_action(config: ConfigType) -> ConfigType: 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.", @@ -267,7 +272,7 @@ async def send_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -316,7 +321,7 @@ async def peer_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) await register_peer(var, config, args) @@ -341,7 +346,7 @@ async def channel_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: 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) diff --git a/esphome/components/espnow/packet_transport/__init__.py b/esphome/components/espnow/packet_transport/__init__.py index e6d66440db..ee4706ca1c 100644 --- a/esphome/components/espnow/packet_transport/__init__.py +++ b/esphome/components/espnow/packet_transport/__init__.py @@ -9,6 +9,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.core import HexInt from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import ESPNowComponent, espnow_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = transport_schema(ESPNowTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: """Set up the ESP-NOW transport component.""" var, _ = await new_packet_transport(config) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 54d7f5c77b..afc39e06a8 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any from esphome import automation import esphome.codegen as cg @@ -20,8 +21,10 @@ from esphome.const import ( PlatformFramework, __version__, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.helpers import IS_MACOS +from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] @@ -63,14 +66,14 @@ CONF_BODY = "body" CONF_JSON = "json" -def validate_url(value): +def validate_url(value: Any) -> str: value = cv.url(value) if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") -def validate_ssl_verification(config): +def validate_ssl_verification(config: ConfigType) -> ConfigType: error_message = "" if CORE.is_rp2 and config[CONF_VERIFY_SSL]: @@ -91,7 +94,7 @@ def validate_ssl_verification(config): return config -def _declare_request_class(value): +def _declare_request_class(value: Any) -> ID: if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: @@ -151,7 +154,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_useragent(config[CONF_USERAGENT])) @@ -298,7 +301,12 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( HTTP_REQUEST_SEND_ACTION_SCHEMA, synchronous=True, ) -async def http_request_action_to_code(config, action_id, template_arg, args): +async def http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index b7026e0f55..784e4ee47a 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,8 +3,10 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import coroutine_with_priority +from esphome.core import ID, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -42,7 +44,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) @@ -72,7 +74,12 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, synchronous=True, ) -async def ota_http_request_action_to_code(config, action_id, template_arg, args): +async def ota_http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index d84d80109a..4bdc30e4cf 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns from ..ota import OtaHttpRequestComponent @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await update.new_update(config) ota_parent = await cg.get_variable(config[CONF_OTA_ID]) cg.add(var.set_ota_parent(ota_parent)) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 4809bf5a92..c5e82beb46 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -21,8 +21,9 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -145,7 +146,7 @@ I2S_MCLK_MULTIPLE = { _validate_bits = cv.float_with_unit("bits", "bit") -def validate_mclk_divisible_by_3(config): +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: raise cv.Invalid( f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" @@ -159,7 +160,7 @@ def i2s_audio_component_schema( default_sample_rate: int, default_channel: str, default_bits_per_sample: str, -): +) -> cv.Schema: return cv.Schema( { cv.GenerateID(): cv.declare_id(class_), @@ -182,7 +183,7 @@ def i2s_audio_component_schema( ) -async def register_i2s_audio_component(var, config): +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] @@ -260,7 +261,7 @@ def _assign_ports() -> None: next_port += 1 -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -275,7 +276,7 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 9c6228087c..c217317237 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, ) +from esphome.types import ConfigType from .. import ( CONF_ADC_TYPE, @@ -46,7 +47,7 @@ I2S_PDM_DSR = { } -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_ADC_TYPE] == "external": if config[CONF_PDM] and variant not in PDM_VARIANTS: @@ -65,13 +66,13 @@ def _validate_esp32_variant(config): raise NotImplementedError -def _validate_channel(config): +def _validate_channel(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] == CONF_MONO: raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") return config -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -80,7 +81,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), @@ -134,7 +135,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_ADC_TYPE] == "internal": raise cv.Invalid( "Internal ADC is no longer supported. Use an external I2S microphone instead." @@ -144,7 +145,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 6d3c39c68e..1849c376aa 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +from esphome.types import ConfigType from .. import ( CONF_I2S_DOUT_PIN, @@ -78,7 +79,7 @@ I2C_COMM_FMT_OPTIONS = { INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -87,7 +88,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: if config.get(CONF_SPDIF_MODE, False): # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( @@ -133,14 +134,14 @@ def _set_stream_limits(config): return config -def _select_speaker_class(config): +def _select_speaker_class(config: ConfigType) -> ConfigType: """Override ID type when SPDIF mode is enabled.""" if config.get(CONF_SPDIF_MODE, False): config[CONF_ID].type = I2SAudioSpeakerSPDIF return config -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: @@ -207,7 +208,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_DAC_TYPE] == "internal": raise cv.Invalid( "Internal DAC is no longer supported. Use an external I2S DAC instead." @@ -238,7 +239,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d53499a78f..755d86e4ea 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID, coroutine +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] @@ -41,7 +43,7 @@ MCP23XXX_CONFIG_SCHEMA = cv.Schema( @coroutine -async def register_mcp23xxx(config, num_pins): +async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: id: ID = config[CONF_ID] var = cg.new_Pvariable(id) await cg.register_component(var, config) @@ -52,7 +54,7 @@ async def register_mcp23xxx(config, num_pins): return var -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -81,7 +83,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) -async def mcp23xxx_pin_to_code(config): +async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] parent = await cg.get_variable(parent_id) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index f3ef6f4917..60cece67d7 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_DISABLE_WIPER_0], diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 99d4988c90..db1a1e6a29 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -34,7 +37,7 @@ CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" VOLATILE_CHANNELS = ("A", "B", "C", "D") -def _validate_nonvolatile(config) -> None: +def _validate_nonvolatile(config: ConfigType) -> None: channel = str(config[CONF_CHANNEL]) # Channels E-H address the nonvolatile registers directly — the mirroring options only @@ -89,7 +92,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( FINAL_VALIDATE_SCHEMA = _validate_nonvolatile -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MCP4461_ID]) var = cg.new_Pvariable( config[CONF_ID], @@ -147,7 +150,12 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( @automation.register_action( "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True ) -async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_step_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -158,7 +166,12 @@ async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args): WIPER_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_store_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, wiper) @@ -169,7 +182,12 @@ async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args): TERMINAL_ACTION_SCHEMA, synchronous=True, ) -async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args): +async def mcp4461_wiper_terminal_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: wiper = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable( action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6b5ee8c3e1..9a3f5b43e7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,8 +14,10 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -50,7 +54,7 @@ IsCapturingCondition = microphone_ns.class_( IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) -async def setup_microphone_core_(var, config): +async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation( @@ -60,7 +64,7 @@ async def setup_microphone_core_(var, config): ) -async def register_microphone(var, config): +async def register_microphone(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) await setup_microphone_core_(var, config) @@ -85,7 +89,7 @@ def microphone_source_schema( max_bits_per_sample: int = 16, min_channels: int = 1, max_channels: int = 1, -): +) -> cv.All: """Schema for a microphone source Components requesting microphone data should use this schema instead of accessing a microphone directly. @@ -97,7 +101,7 @@ def microphone_source_schema( max_channels (int, optional): Maximum number of channels the requesting component supports. Defaults to 1. """ - def _validate_unique_channels(config): + def _validate_unique_channels(config: list[int]) -> list[int]: if len(config) != len(set(config)): raise cv.Invalid("Channels must be unique") return config @@ -124,7 +128,7 @@ def microphone_source_schema( def final_validate_microphone_source_schema( component_name: str, sample_rate: int = cv.UNDEFINED -): +) -> Callable[[ConfigType], ConfigType]: """Validates that the microphone source can provide audio in the correct format. In particular it validates the sample rate and the enabled channels. Note that: @@ -136,7 +140,7 @@ def final_validate_microphone_source_schema( sample_rate (int, optional): The sample rate the component requesting mic audio requires """ - def _validate_audio_compatability(config): + def _validate_audio_compatability(config: ConfigType) -> ConfigType: if sample_rate is not cv.UNDEFINED: # Issues require changing the microphone configuration # - Verifies sample rates match @@ -161,7 +165,9 @@ def final_validate_microphone_source_schema( return _validate_audio_compatability -async def microphone_source_to_code(config, passive=False): +async def microphone_source_to_code( + config: ConfigType, passive: bool = False +) -> MockObj: """Creates a MicrophoneSource variable for codegen. Setting passive to true makes the MicrophoneSource never start/stop the microphone, but only receives audio when another component has actively started the Microphone. If false, then the microphone needs to be explicitly started/stopped. @@ -183,7 +189,12 @@ async def microphone_source_to_code(config, passive=False): return mic_source -async def microphone_action(config, action_id, template_arg, args): +async def microphone_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -219,6 +230,6 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(microphone_ns.using) cg.add_define("USE_MICROPHONE") diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index f34df21647..6258932312 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] AUTO_LOAD = ["binary_sensor", "nfc"] @@ -41,7 +44,7 @@ PN532_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -def CONFIG_SCHEMA(conf): +def CONFIG_SCHEMA(conf: ConfigType) -> None: if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " @@ -56,7 +59,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn532(var, config): +async def setup_pn532(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) for conf in config.get(CONF_ON_TAG, []): @@ -85,7 +88,12 @@ async def setup_pn532(var, config): } ), ) -async def pn532_is_writing_to_code(config, condition_id, template_arg, args): +async def pn532_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index b9c3103c65..8f490ba7d0 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_PN532_ID, PN532, pn532_ns DEPENDENCIES = ["pn532"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 9dd3e8c5b0..4638992abf 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -107,7 +110,12 @@ PN7150_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_set_message_to_code(config, action_id, template_arg, args): +async def pn7150_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -158,7 +166,12 @@ async def pn7150_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7150_simple_action_to_code(config, action_id, template_arg, args): +async def pn7150_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -174,7 +187,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7150(var, config): +async def setup_pn7150(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) @@ -216,7 +229,12 @@ async def setup_pn7150(var, config): } ), ) -async def pn7150_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7150_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index ef14a29099..7f9f9172a1 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -12,6 +12,9 @@ from esphome.const import ( CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor", "nfc"] CODEOWNERS = ["@kbx81", "@jesserockz"] @@ -111,7 +114,12 @@ PN7160_SCHEMA = cv.Schema( SET_MESSAGE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_set_message_to_code(config, action_id, template_arg, args): +async def pn7160_set_message_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) @@ -162,7 +170,12 @@ async def pn7160_set_message_to_code(config, action_id, template_arg, args): SIMPLE_ACTION_SCHEMA, synchronous=True, ) -async def pn7160_simple_action_to_code(config, action_id, template_arg, args): +async def pn7160_simple_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -178,7 +191,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def setup_pn7160(var, config): +async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): @@ -228,7 +241,12 @@ async def setup_pn7160(var, config): } ), ) -async def pn7160_is_writing_to_code(config, condition_id, template_arg, args): +async def pn7160_is_writing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var From 8ef0f38f4efff4974bb1e96a210e128d99feb2fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 08:59:30 -0500 Subject: [PATCH 296/597] [ethernet] Skip the custom W5500 SPI driver for other ethernet types (#18533) --- esphome/components/ethernet/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 5eda0fc12c..7686b64cb4 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -811,6 +811,10 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, + "w5500_custom_spi.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, } ) @@ -830,6 +834,11 @@ def _filter_source_files() -> list[str]: # to avoid shadowing. Native IDF builds always need the custom driver. if cv.Version(5, 4, 2) <= idf_version() < cv.Version(6, 0, 0): excluded.append("esp_eth_phy_jl1101.c") + # The custom W5500 SPI driver is fully #ifdef'd on USE_ESP32 and + # USE_ETHERNET_W5500 (the platform filter map above handles non-ESP32); + # skip it entirely for the other ethernet types. + if eth_type != "W5500": + excluded.append("w5500_custom_spi.cpp") return excluded From 2d62ea78d203727c0f20a824add2b78271f33d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:00:11 -0500 Subject: [PATCH 297/597] [ota] Skip partition-access OTA sources when the feature is disabled (#18532) --- esphome/components/ota/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 2d4de52e8f..1e2ee947c1 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -182,4 +182,11 @@ def FILTER_SOURCE_FILES() -> list[str]: for define in CORE.defines ): files.append("ota_signature_esp_idf.cpp") + # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully + # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when + # allow_partition_access is enabled). Filter them out otherwise for the + # same reason as above. + if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): + files.append("ota_bootloader_esp_idf.cpp") + files.append("ota_partitions_esp_idf.cpp") return files From a8e721abebdda3a42b1b6ecf391a90938b2187ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:03:49 -0500 Subject: [PATCH 298/597] [esp32] Apply IDF component exclusions to native toolchain builds (#18531) --- esphome/build_gen/espidf.py | 41 +++++++- esphome/components/esp32/__init__.py | 19 ++-- esphome/espidf/toolchain.py | 24 ++++- tests/unit_tests/build_gen/test_espidf.py | 115 +++++++++++++++++++--- tests/unit_tests/test_espidf_toolchain.py | 69 +++++++++++++ 5 files changed, 243 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cf476555e7..b65ce23307 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,12 @@ import json from pathlib import Path -from esphome.components.esp32 import get_esp32_variant, idf_version +from esphome.components.esp32 import ( + get_esp32_variant, + get_excluded_builtin_components, + get_managed_component_require_names, + idf_version, +) import esphome.config_validation as cv from esphome.core import CORE from esphome.framework_helpers import ( @@ -119,24 +124,40 @@ def get_project_cmakelists(minimal: bool = False) -> str: # runs as a separate CMake script invocation that doesn't load the # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_ # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty). - from esphome.components.esp32 import get_managed_component_require_names - managed_components_property = "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" for name in get_managed_component_require_names() ) + # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS + # minus per-component re-includes). project.cmake reads the plain + # EXCLUDE_COMPONENTS variable when seeding the component list, so this + # must be set before project(). Emitted on minimal writes too so the + # discovery reconfigure never registers the excluded components. + excluded_components = get_excluded_builtin_components() + exclude_components_var = ( + f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' + if excluded_components + else "" + ) + # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. + # Excluded components are dropped here as well: a stale + # project_description.json from a build without exclusions may still + # list them, and requiring an excluded component pulls it back into + # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" - for name in sorted(get_available_components() or []) + for name in sorted( + set(get_available_components() or []).difference(excluded_components) + ) ) ) @@ -165,6 +186,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{exclude_components_var} + {cpp_standard_options} {cxx_compile_options} @@ -264,3 +287,13 @@ def write_project(minimal: bool = False) -> None: CORE.relative_src_path("CMakeLists.txt"), get_component_cmakelists(), ) + + # Snapshot the exclusion set so has_outdated_files() can trigger a + # discovery reconfigure when it changes. Excluded components never + # register in project_description.json, so re-including one (e.g. a + # config gains mqtt) requires a fresh discovery pass before the + # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + write_file_if_changed( + CORE.relative_build_path("exclude_components.esphomeinternal"), + ";".join(get_excluded_builtin_components()), + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3065cdadad..d6e0890751 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -738,6 +738,16 @@ def include_builtin_idf_component(name: str) -> None: CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) +def get_excluded_builtin_components() -> list[str]: + """Return the sorted built-in IDF components excluded from the build. + + Single accessor for both build writers: the PlatformIO path passes it as + ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the + generated CMakeLists. + """ + return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) + + def _enable_arduino_library(name: str) -> None: """Enable an Arduino library that is disabled by default. @@ -2122,13 +2132,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if KEY_ESP32 not in CORE.data: - return - excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) - if excluded: - exclude_list = ";".join(sorted(excluded)) + if excluded := get_excluded_builtin_components(): cg.add_platformio_option( - "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" + "board_build.cmake_extra_args", + f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", ) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index bb6452acf2..07ba03e2cf 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -273,6 +273,11 @@ def has_outdated_files(): happen without any sdkconfig impact, and ``_write_idf_component_yml`` already deletes ``dependencies.lock`` on a change but that signal gets lost as soon as the lock is missing. + - ``exclude_components.esphomeinternal`` -- the resolved + EXCLUDE_COMPONENTS set. Excluded components never register in + ``project_description.json``, so re-including one needs a fresh + discovery pass before it can appear in the builtin-components + property that ``src`` REQUIRES. We deliberately don't watch: - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and @@ -291,6 +296,9 @@ def has_outdated_files(): f"sdkconfig.{CORE.name}.esphomeinternal" ) idf_component_yml_path = CORE.relative_build_path("src/idf_component.yml") + exclude_components_path = CORE.relative_build_path( + "exclude_components.esphomeinternal" + ) dependency_lock_path = CORE.relative_build_path("dependencies.lock") build_ninja_path = CORE.relative_build_path("build/build.ninja") @@ -309,7 +317,11 @@ def has_outdated_files(): cmakecache_txt_mtime = cmakecache_txt_path.stat().st_mtime return any( f.stat().st_mtime > cmakecache_txt_mtime - for f in [sdkconfig_internal_path, idf_component_yml_path] + for f in [ + sdkconfig_internal_path, + idf_component_yml_path, + exclude_components_path, + ] if f.exists() ) @@ -386,6 +398,16 @@ def run_compile(config, verbose: bool) -> int: return rc _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") write_project(minimal=False) + # Restamp the reference file has_outdated_files() compares against. + # A reconfigure that only changes properties or plain variables + # (sdkconfig options, the exclusion set) does not rewrite + # CMakeCache.txt, so without this the watched inputs stay newer + # forever and every subsequent build repeats the discovery pass. + # Done after the full write so an interrupt cannot leave a minimal + # CMakeLists behind that is already marked fresh. + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + if cmakecache.is_file(): + os.utime(cmakecache) if CORE.testing_mode: # Reconfigure again so cmake is up to date with the full # component list before the build's idf.py invocation runs -- diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index f21549b48c..ec01000920 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -11,6 +11,7 @@ import pytest from esphome.components.esp32 import ( KEY_COMPONENTS, KEY_ESP32, + KEY_EXCLUDE_COMPONENTS, KEY_IDF_VERSION, KEY_PATH, KEY_REF, @@ -28,6 +29,7 @@ def _reset_core(tmp_path: Path) -> None: CORE.data.setdefault(KEY_CORE, {}) CORE.data[KEY_ESP32] = { KEY_COMPONENTS: {}, + KEY_EXCLUDE_COMPONENTS: set(), KEY_IDF_VERSION: cv.Version(5, 5, 4), } @@ -47,6 +49,17 @@ def _write_project_description(tmp_path: Path, components: dict[str, str]) -> No ) +def _render(minimal: bool = False) -> str: + """Render the top-level CMakeLists with the standard variant/name patches.""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + return get_project_cmakelists(minimal=minimal) + + def test_get_available_components_returns_none_without_build_path() -> None: """No build_path set yet: must not raise on Path(None).""" CORE.build_path = None @@ -88,13 +101,7 @@ def test_get_project_cmakelists_minimal_omits_builtin_components_property( first write before the discovery pass refreshes it).""" _write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"}) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=True) + content = _render(minimal=True) assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content @@ -115,13 +122,7 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( }, ) - with ( - patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), - patch.object(CORE, "name", "test"), - ): - from esphome.build_gen.espidf import get_project_cmakelists - - content = get_project_cmakelists(minimal=False) + content = _render() assert ( "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)" @@ -136,6 +137,92 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: + """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are + dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale + project_description.json still lists them (requiring an excluded + component would pull it back into the build).""" + _write_project_description( + tmp_path, + { + "esp_lcd": "/idf/components/esp_lcd", + "freertos": "/idf/components/freertos", + "unity": "/idf/components/unity", + }, + ) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "esp_lcd;unity")' in content + # Must be set before project() so project.cmake sees it. + assert content.index("set(EXCLUDE_COMPONENTS") < content.index("project(test)") + assert ( + "idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)" + in content + ) + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS unity" not in content + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content + + +def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: + """The discovery (minimal) write also excludes components so they never + register in project_description.json.""" + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + + content = _render(minimal=True) + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + + +def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: + """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + content = _render() + + assert "EXCLUDE_COMPONENTS" not in content + + +def test_include_builtin_idf_component_removes_exclusion() -> None: + """include_builtin_idf_component() drops a name from the exclusion set so + a component a config actually uses is not passed to EXCLUDE_COMPONENTS.""" + from esphome.components.esp32 import ( + exclude_builtin_idf_component, + get_excluded_builtin_components, + include_builtin_idf_component, + ) + + exclude_builtin_idf_component("esp_eth") + exclude_builtin_idf_component("unity") + include_builtin_idf_component("esp_eth") + + assert get_excluded_builtin_components() == ["unity"] + + content = _render() + + assert 'set(EXCLUDE_COMPONENTS "unity")' in content + assert "esp_eth" not in content + + +def test_write_project_writes_exclude_components_stamp(tmp_path: Path) -> None: + """write_project() snapshots the exclusion set; the toolchain watches the + stamp to trigger a discovery reconfigure when the set changes (excluded + components never register in project_description.json).""" + CORE.build_flags = set() + CORE.build_path = tmp_path + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + ): + from esphome.build_gen.espidf import write_project + + write_project() + + stamp = tmp_path / "exclude_components.esphomeinternal" + assert stamp.read_text() == "esp_lcd;unity" + + def test_get_component_cmakelists_no_link_flags() -> None: """With no -Wl, flags the target_link_options block is emitted with an empty body.""" CORE.build_flags = set() diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 26d812af8b..2556397aef 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -100,6 +100,33 @@ def _setup_build(setup_core: Path) -> tuple[Path, Path]: return compile_commands, cache +def test_has_outdated_files_detects_exclusion_change(setup_core: Path) -> None: + """A newer exclude_components.esphomeinternal stamp forces a reconfigure + so components that leave the exclusion set get rediscovered.""" + CORE.build_path = setup_core + build = setup_core / "build" + (build / "config").mkdir(parents=True) + (build / "config" / "sdkconfig.h").write_text("") + cmakecache = build / "CMakeCache.txt" + cmakecache.write_text("") + (build / "build.ninja").write_text("") + + with patch.object(CORE, "name", "test"): + assert not toolchain.has_outdated_files() + + stamp = setup_core / "exclude_components.esphomeinternal" + stamp.write_text("unity") + os.utime(stamp, (cmakecache.stat().st_mtime + 10,) * 2) + + assert toolchain.has_outdated_files() + + # The flag must clear once the reference file is restamped (as + # run_compile does after a successful discovery reconfigure); + # otherwise every later build would repeat the discovery pass. + os.utime(cmakecache, (stamp.stat().st_mtime + 10,) * 2) + assert not toolchain.has_outdated_files() + + def test_get_idedata_returns_none_without_compile_commands(setup_core: Path) -> None: """No compile DB yet -> None (rather than an error).""" _setup_build(setup_core) @@ -373,6 +400,48 @@ def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: assert "IDF_PY_BUILD_JOBS" not in env +def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> None: + """After a successful discovery reconfigure the reference CMakeCache.txt + is restamped; cmake does not rewrite it when only properties or plain + variables change, so the staleness flag would otherwise never clear.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + cmakecache = CORE.relative_build_path("build/CMakeCache.txt") + cmakecache.parent.mkdir(parents=True, exist_ok=True) + cmakecache.write_text("") + old = cmakecache.stat().st_mtime - 100 + os.utime(cmakecache, (old, old)) + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert cmakecache.stat().st_mtime > old + + +def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: + """A discovery pass that produced no CMakeCache.txt (nothing to restamp) + still completes normally.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch("esphome.build_gen.espidf.write_project"), + patch.object(toolchain, "run_reconfigure", return_value=0), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + assert not CORE.relative_build_path("build/CMakeCache.txt").exists() + + def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: """compile_process_limit is forwarded to run_idf_py as the job limit.""" _setup_build(setup_core) From 347a6155f8783342d1bb7da05ad4a1254fe47f45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 09:07:17 -0500 Subject: [PATCH 299/597] [uptime] Skip the timestamp sensor source when no time component is configured (#18535) --- esphome/components/uptime/sensor/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index e2a7aee1a2..debeb41444 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import CORE uptime_ns = cg.esphome_ns.namespace("uptime") UptimeSecondsSensor = uptime_ns.class_( @@ -59,3 +60,11 @@ async def to_code(config): if time_id_config := config.get(CONF_TIME_ID): time_id = await cg.get_variable(time_id_config) cg.add(var.set_time(time_id)) + + +def FILTER_SOURCE_FILES() -> list[str]: + # uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it + # when no time component is configured. + if not any(define.name == "USE_TIME" for define in CORE.defines): + return ["uptime_timestamp_sensor.cpp"] + return [] From ecca240eef2b79baa85d3eca6665a333e11c0e62 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:26:16 +1200 Subject: [PATCH 300/597] [core] Add type annotations to component Python (2/11) (#18339) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/cm1106/sensor.py | 12 +++++-- esphome/components/esp_ldo/__init__.py | 22 +++++++++---- esphome/components/ili9xxx/display.py | 13 ++++---- esphome/components/it8951/display.py | 32 +++++++++++++------ esphome/components/mapping/__init__.py | 17 ++++++---- esphome/components/mipi_dsi/display.py | 9 +++--- esphome/components/mipi_rgb/display.py | 14 ++++---- esphome/components/mipi_rgb/models/st7701s.py | 2 +- esphome/components/mipi_spi/display.py | 15 +++++---- esphome/components/online_image/image.py | 10 ++++-- .../components/packet_transport/__init__.py | 27 +++++++++------- .../packet_transport/binary_sensor.py | 5 +-- esphome/components/packet_transport/sensor.py | 3 +- esphome/components/pca9554/__init__.py | 12 ++++--- esphome/components/qspi_dbi/display.py | 16 ++++++---- esphome/components/qspi_dbi/models.py | 10 +++--- esphome/components/rpi_dpi_rgb/display.py | 9 ++++-- esphome/components/sdl/binary_sensor.py | 3 +- esphome/components/sdl/display.py | 9 ++++-- .../components/sdl/touchscreen/__init__.py | 3 +- esphome/components/seeed_mr24hpc1/__init__.py | 3 +- .../seeed_mr24hpc1/binary_sensor.py | 3 +- .../seeed_mr24hpc1/button/__init__.py | 3 +- .../seeed_mr24hpc1/number/__init__.py | 3 +- .../seeed_mr24hpc1/select/__init__.py | 3 +- esphome/components/seeed_mr24hpc1/sensor.py | 3 +- .../seeed_mr24hpc1/switch/__init__.py | 3 +- .../components/seeed_mr24hpc1/text_sensor.py | 3 +- esphome/components/seeed_mr60bha2/__init__.py | 3 +- .../seeed_mr60bha2/binary_sensor.py | 3 +- esphome/components/seeed_mr60bha2/sensor.py | 3 +- esphome/components/seeed_mr60fda2/__init__.py | 3 +- .../seeed_mr60fda2/binary_sensor.py | 3 +- .../seeed_mr60fda2/button/__init__.py | 3 +- .../seeed_mr60fda2/select/__init__.py | 3 +- esphome/components/st7701s/display.py | 11 ++++--- esphome/components/st7701s/init_sequences.py | 2 +- esphome/components/udp/__init__.py | 22 +++++++++---- .../udp/packet_transport/__init__.py | 3 +- esphome/components/usb_uart/__init__.py | 19 +++++------ 40 files changed, 220 insertions(+), 125 deletions(-) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 3c82fac977..936c5fc673 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] @@ -44,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: """Code generation entry point.""" var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -67,7 +70,12 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: +async def cm1106_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: """Service code generation entry point.""" paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index a489651b59..46810d422d 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome.automation import Action, register_action import esphome.codegen as cg from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_VOLTAGE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -22,7 +27,7 @@ CONF_PASSTHROUGH = "passthrough" adjusted_ids = set() -def validate_ldo_voltage(value): +def validate_ldo_voltage(value: Any) -> str | float: if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH: return CONF_PASSTHROUGH value = cv.voltage(value) @@ -33,7 +38,7 @@ def validate_ldo_voltage(value): ) -def validate_ldo_config(config): +def validate_ldo_config(config: ConfigType) -> ConfigType: channel = config[CONF_CHANNEL] allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL] if allow_internal and channel not in CHANNELS_INTERNAL: @@ -77,7 +82,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: for config in configs: var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL]) await cg.register_component(var, config) @@ -89,7 +94,7 @@ async def to_code(configs): cg.add(var.set_adjustable(config[CONF_ADJUSTABLE])) -def final_validate(configs): +def final_validate(configs: list[ConfigType]) -> None: for channel in CHANNELS: used = [config for config in configs if config[CONF_CHANNEL] == channel] if len(used) > 1: @@ -112,7 +117,7 @@ def final_validate(configs): FINAL_VALIDATE_SCHEMA = final_validate -def adjusted_ldo_id(value): +def adjusted_ldo_id(value: Any) -> ID: value = cv.use_id(EspLdo)(value) adjusted_ids.add(value) return value @@ -131,7 +136,12 @@ def adjusted_ldo_id(value): ), synchronous=True, ) -async def ldo_voltage_adjust_to_code(config, action_id, template_arg, args): +async def ldo_voltage_adjust_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) template_ = await cg.templatable(config[CONF_VOLTAGE], args, cg.float_) diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index b1d332c1e5..64f87c167c 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -31,6 +31,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.final_validate import full_config +from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -91,7 +92,7 @@ CONF_INVERT_DISPLAY = "invert_display" CONF_PIXEL_MODE = "pixel_mode" -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) @@ -101,7 +102,7 @@ def cmd(c, *args): return [c, len(args)] + list(args) -def map_sequence(value): +def map_sequence(value: list[int]) -> list[int]: """ An initialisation sequence is a literal array of data bytes. The format is a repeated sequence of [CMD, ] @@ -111,7 +112,7 @@ def map_sequence(value): return cmd(*value) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if ( config.get(CONF_COLOR_PALETTE) == "IMAGE_ADAPTIVE" and CONF_COLOR_PALETTE_IMAGES not in config @@ -196,7 +197,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: global_config = full_config.get() # Ideally would calculate buffer size here, but that info is not available on the Python side needs_buffer = ( @@ -218,7 +219,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." ) @@ -278,7 +279,7 @@ async def to_code(config): cg.add(var.set_buffer_color_mode(ILI9XXXColorMode.BITS_8_INDEXED)) from PIL import Image - def load_image(filename): + def load_image(filename: str) -> Image.Image: path = CORE.relative_config_path(filename) try: return Image.open(path) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index bdc68b5257..57bf86c4c6 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -2,6 +2,9 @@ ESPHome configuration for the IT8951 e-paper controller. """ +from collections.abc import Callable +from typing import Any + from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import display, spi @@ -33,8 +36,10 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.cpp_generator import RawExpression +from esphome.core import ID +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] @@ -97,16 +102,16 @@ class IT8951Model: models: dict[str, "IT8951Model"] = {} - def __init__(self, name: str, **defaults): + def __init__(self, name: str, **defaults: Any) -> None: name = name.upper() self.name = name self.defaults = defaults IT8951Model.models[name] = self - def get_default(self, key, fallback=None): + def get_default(self, key: str, fallback: Any = None) -> Any: return self.defaults.get(key, fallback) - def get_dimensions(self, config) -> tuple[int, int]: + def get_dimensions(self, config: ConfigType) -> tuple[int, int]: # If dimensions are in config, use them; otherwise fall back to model defaults. if CONF_DIMENSIONS in config: dimensions = config[CONF_DIMENSIONS] @@ -181,14 +186,16 @@ DIMENSION_SCHEMA = cv.Schema( ) -def _model_pin_option(model, key, schema): +def _model_pin_option( + model: IT8951Model, key: str, schema: Callable[[Any], Any] +) -> tuple[cv.Optional | cv.Required, Callable[[Any], Any]]: default = model.get_default(key) if default is None: return cv.Required(key), schema return cv.Optional(key, default=default), schema -def _model_schema(config): +def _model_schema(config: ConfigType) -> cv.Schema: model = IT8951Model.models[config[CONF_MODEL]] has_default_dimensions = ( model.get_default(CONF_WIDTH) is not None @@ -293,7 +300,7 @@ def _model_schema(config): return schema.extend(pin_extra) -def _customise_schema(config): +def _customise_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of( @@ -336,7 +343,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( config @@ -356,7 +363,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = IT8951Model.models[config[CONF_MODEL]] width, height = model.get_dimensions(config) @@ -423,7 +430,12 @@ async def to_code(config): ), synchronous=True, ) -async def it8951_update_action_to_code(config, action_id, template_arg, args): +async def it8951_update_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: display_var = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, display_var) if mode := config.get(CONF_MODE): diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index 3c7d78a27b..cd846877ae 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -1,5 +1,6 @@ from collections.abc import Callable import difflib +from typing import Any import esphome.codegen as cg from esphome.components.const import KEY_METADATA @@ -13,6 +14,7 @@ from esphome.cpp_generator import ( add_global, ) from esphome.loader import get_component +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True @@ -32,13 +34,16 @@ class IndexType: """ def __init__( - self, validator: Callable, data_type: MockObj, conversion: Callable = None + self, + validator: Callable, + data_type: MockObj, + conversion: Callable | None = None, ) -> None: self.validator = validator self.data_type = data_type self.conversion = conversion - async def convert_value(self, value): + async def convert_value(self, value: Any) -> Any: if self.conversion: return self.conversion(value) return await cg.get_variable(value) @@ -60,7 +65,7 @@ class MappingMetaData: self.to_ = to_ -def to_schema(value): +def to_schema(value: Any) -> str: """ Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name. :param value: @@ -82,7 +87,7 @@ BASE_SCHEMA = cv.Schema( ) -def get_object_type(to_) -> MockObjClass | None: +def get_object_type(to_: str) -> MockObjClass | None: """ Get the object type from a string. Possible formats: xxx The name of a component which defines INSTANCE_TYPE @@ -121,7 +126,7 @@ def add_metadata( get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_) -def map_schema(config): +def map_schema(config: ConfigType) -> ConfigType: config = BASE_SCHEMA(config) if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict): raise cv.Invalid("an entries dictionary is required for a mapping") @@ -163,7 +168,7 @@ def map_schema(config): CONFIG_SCHEMA = map_schema -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: varid = config[CONF_ID] metadata = get_mapping_metadata(varid.id) entries = { diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 8c125a9606..b23982655a 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -53,6 +53,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import mipi_dsi_ns, models from .models import DsiDriverChip @@ -85,7 +86,7 @@ COLOR_DEPTHS = { } -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence @@ -148,7 +149,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -175,7 +176,7 @@ def _config_schema(config): return config -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -189,7 +190,7 @@ CONFIG_SCHEMA = _config_schema FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 897088a257..e23e19a000 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -1,5 +1,6 @@ import importlib import pkgutil +from typing import Any from esphome import pins import esphome.codegen as cg @@ -72,6 +73,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import models from .models import RgbDriverChip @@ -97,7 +99,7 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = DriverChip.get_models() -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -112,14 +114,14 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.All: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.Schema: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list @@ -213,7 +215,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -248,7 +250,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -265,7 +267,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index a20e9d1c01..cad5dc8e20 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -8,7 +8,7 @@ SDIR_CMD = 0xC7 class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring - def add_madctl(self, sequence: list, config: dict): + def add_madctl(self, sequence: list, config: dict) -> int: transform = self.get_transform(config) madctl = 0x00 if config[CONF_COLOR_ORDER] == MODE_BGR: diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 246db237b1..e8b54da5c7 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -53,8 +53,9 @@ from esphome.const import ( CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.cpp_generator import TemplateArguments +from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.final_validate import full_config +from esphome.types import ConfigType from . import CONF_BUS_MODE, CONF_SPI_16, DOMAIN, models @@ -110,7 +111,7 @@ DISPLAY_PIXEL_MODES = { } -def denominator(config): +def denominator(config: ConfigType) -> int: """ Calculate the best denominator for a buffer size fraction. The denominator should be a number between 2 and 16 that divides the display height evenly, @@ -132,7 +133,7 @@ def denominator(config): return next(x for x in range(2, 17) if frac >= 1 / x) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All | cv.Schema: model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] transform = model.transform_schema() @@ -238,7 +239,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) -def customise_schema(config): +def customise_schema(config: ConfigType) -> ConfigType: """ Create a customised config schema for a specific model and validate the configuration. :param config: The configuration dictionary to validate @@ -305,7 +306,7 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() model = MODELS[config[CONF_MODEL]] @@ -341,7 +342,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -def get_instance(config): +def get_instance(config: ConfigType) -> tuple[MockObjClass, list]: """ Get the type of MipiSpi instance to create based on the configuration, and the template arguments. @@ -394,7 +395,7 @@ def get_instance(config): return MipiSpi, templateargs -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index cb86f93e29..ae785d17f9 100644 --- a/esphome/components/online_image/image.py +++ b/esphome/components/online_image/image.py @@ -6,7 +6,8 @@ from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestCom from esphome.components.image import CONF_TRANSPARENCY, add_metadata import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda +from esphome.core import ID, Lambda +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["runtime_image"] @@ -89,7 +90,12 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( RELEASE_IMAGE_SCHEMA, synchronous=True, ) -async def online_image_action_to_code(config, action_id, template_arg, args): +async def online_image_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 7beb13ca31..c36d421a35 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -1,7 +1,9 @@ """ESPHome packet transport component.""" +from collections.abc import Callable, Iterator import hashlib import logging +from typing import Any import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor @@ -17,8 +19,9 @@ from esphome.const import ( CONF_PLATFORM, CONF_SENSORS, ) -from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] AUTO_LOAD = ["xxtea"] @@ -43,7 +46,7 @@ CONF_TRANSPORT_ID = "transport_id" _LOGGER = logging.getLogger(__name__) -def sensor_validation(cls: MockObjClass): +def sensor_validation(cls: MockObjClass) -> Callable[[Any], Any]: return cv.maybe_simple_value( cv.Schema( { @@ -55,7 +58,7 @@ def sensor_validation(cls: MockObjClass): ) -def provider_name_validate(value): +def provider_name_validate(value: Any) -> str: value = cv.valid_name(value) if "_" in value: _LOGGER.warning( @@ -83,7 +86,7 @@ PROVIDER_SCHEMA = cv.Schema( ).extend(ENCRYPTION_SCHEMA) -def validate_(config): +def validate_(config: ConfigType) -> ConfigType: if CONF_ENCRYPTION in config: if CONF_SENSORS not in config and CONF_BINARY_SENSORS not in config: raise cv.Invalid("No sensors or binary sensors to encrypt") @@ -117,11 +120,11 @@ TRANSPORT_SCHEMA = ( ) -def transport_schema(cls): +def transport_schema(cls: MockObjClass) -> cv.Schema: return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)}) -def get_sensors(transport_id): +def get_sensors(transport_id: ID) -> Iterator[ConfigType]: """Return the list of sensors for this platform.""" return ( sensor @@ -130,7 +133,7 @@ def get_sensors(transport_id): ) -def validate_packet_transport_sensor(config): +def validate_packet_transport_sensor(config: ConfigType) -> ConfigType: if CONF_NAME in config and CONF_INTERNAL not in config: raise cv.Invalid("Must provide internal: config when using name:") conf_sensors = CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) @@ -138,7 +141,7 @@ def validate_packet_transport_sensor(config): return config -def packet_transport_sensor_schema(base_schema): +def packet_transport_sensor_schema(base_schema: cv.Schema) -> cv.Schema: return cv.All( base_schema.extend( { @@ -152,11 +155,11 @@ def packet_transport_sensor_schema(base_schema): ) -def hash_encryption_key(config: dict): +def hash_encryption_key(config: dict) -> list[int]: return list(hashlib.sha256(config[CONF_KEY].encode()).digest()) -async def register_packet_transport(var, config): +async def register_packet_transport(var: MockObj, config: ConfigType) -> set[str]: var = await cg.register_component(var, config) cg.add(var.set_rolling_code_enable(config[CONF_ROLLING_CODE_ENABLE])) cg.add(var.set_ping_pong_enable(config[CONF_PING_PONG_ENABLE])) @@ -203,7 +206,7 @@ async def register_packet_transport(var, config): return providers -async def new_packet_transport(config): +async def new_packet_transport(config: ConfigType) -> tuple[MockObj, set[str]]: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_platform_name(config[CONF_PLATFORM])) providers = await register_packet_transport(var, config) diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 3291ff2c59..37c4688242 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ) import esphome.final_validate as fv +from esphome.types import ConfigType from . import ( CONF_ENCRYPTION, @@ -44,7 +45,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: if config[CONF_TYPE] != CONF_STATUS: # Only run this validation if a status sensor is being configured return @@ -65,7 +66,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) comp = await cg.get_variable(config[CONF_TRANSPORT_ID]) if config[CONF_TYPE] == CONF_STATUS: diff --git a/esphome/components/packet_transport/sensor.py b/esphome/components/packet_transport/sensor.py index 15c0e33b30..018f1c3a9b 100644 --- a/esphome/components/packet_transport/sensor.py +++ b/esphome/components/packet_transport/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components.sensor import new_sensor, sensor_schema from esphome.const import CONF_ID +from esphome.types import ConfigType from . import ( CONF_PROVIDER, @@ -12,7 +13,7 @@ from . import ( CONFIG_SCHEMA = packet_transport_sensor_schema(sensor_schema()) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await new_sensor(config) comp = await cg.get_variable(config[CONF_TRANSPORT_ID]) remote_id = str(config.get(CONF_REMOTE_ID) or config.get(CONF_ID)) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index f49a68bc3f..5272df2b55 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -11,6 +11,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] AUTO_LOAD = ["gpio_expander"] @@ -40,7 +42,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) @@ -49,7 +51,7 @@ async def to_code(config): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -69,7 +71,9 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( ) -def pca9554_pin_final_validate(pin_config, parent_config): +def pca9554_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: count = parent_config[CONF_PIN_COUNT] if pin_config[CONF_NUMBER] >= count: raise cv.Invalid(f"Pin number must be in range 0-{count - 1}") @@ -78,7 +82,7 @@ def pca9554_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) -async def pca9554_pin_to_code(config): +async def pca9554_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_PCA9554]) diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 48cd72ecdf..dce1a95687 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -26,6 +27,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from . import CONF_DRAW_FROM_ORIGIN from .models import DriverChip @@ -49,14 +51,14 @@ DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema DELAY_FLAG = 0xFF -def validate_dimension(value): +def validate_dimension(value: Any) -> int: value = cv.positive_int(value) if value % 2 != 0: raise cv.Invalid("Width/height/offset must be divisible by 2") return value -def map_sequence(value): +def map_sequence(value: Any) -> list[int]: """ The format is a repeated sequence of [CMD, ] where is s a sequence of bytes. The length is inferred from the length of the sequence and should not be explicit. @@ -74,14 +76,14 @@ def map_sequence(value): return [value[0], len(params)] + list(params) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: chip = DriverChip.chips[config[CONF_MODEL]] if not chip.initsequence and CONF_INIT_SEQUENCE not in config: raise cv.Invalid(f"{chip.name} model requires init_sequence") return config -def power_of_two(value): +def power_of_two(value: Any) -> int: value = cv.int_range(1, 128)(value) if value & (value - 1) != 0: raise cv.Invalid("value must be a power of two") @@ -122,11 +124,11 @@ BASE_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( ) -def model_property(name, defaults, fallback): +def model_property(name: str, defaults: dict[str, Any], fallback: Any) -> cv.Optional: return cv.Optional(name, default=defaults.get(name, fallback)) -def model_schema(defaults): +def model_schema(defaults: dict[str, Any]) -> cv.Schema: transform = cv.Schema( { cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, @@ -162,7 +164,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." ) diff --git a/esphome/components/qspi_dbi/models.py b/esphome/components/qspi_dbi/models.py index 8ce592e0cf..7611279509 100644 --- a/esphome/components/qspi_dbi/models.py +++ b/esphome/components/qspi_dbi/models.py @@ -1,4 +1,6 @@ # Commands +from typing import Any + from esphome.components.const import CONF_DRAW_ROUNDING from esphome.const import CONF_INVERT_COLORS, CONF_SWAP_XY @@ -26,16 +28,16 @@ PAGESEL = 0xFE class DriverChip: - chips = {} + chips: dict[str, "DriverChip"] = {} - def __init__(self, name: str, defaults=None): + def __init__(self, name: str, defaults: dict[str, Any] | None = None) -> None: name = name.upper() self.name = name self.chips[name] = self self.initsequence = [] self.defaults = defaults or {} - def cmd(self, c, *args): + def cmd(self, c: int, *args: int) -> None: """ Add a command sequence to the init sequence :param c: The command (8 bit) @@ -43,7 +45,7 @@ class DriverChip: """ self.initsequence.extend([c, len(args)] + list(args)) - def delay(self, ms): + def delay(self, ms: int) -> None: self.initsequence.extend([ms, 0xFF]) diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 314852832c..1ca29a3259 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -38,6 +40,7 @@ from esphome.const import ( CONF_VSYNC_PIN, CONF_WIDTH, ) +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] LOGGER = logging.getLogger(__name__) @@ -53,7 +56,7 @@ COLOR_ORDERS = { DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -68,7 +71,7 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> Callable[[Any], Any]: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), @@ -128,7 +131,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." ) diff --git a/esphome/components/sdl/binary_sensor.py b/esphome/components/sdl/binary_sensor.py index e19a488800..0fdda25ed3 100644 --- a/esphome/components/sdl/binary_sensor.py +++ b/esphome/components/sdl/binary_sensor.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_KEY from esphome.core import Lambda from esphome.cpp_generator import ExpressionStatement, RawExpression +from esphome.types import ConfigType from .display import CONF_SDL_ID, Sdl @@ -275,7 +276,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) parent = await cg.get_variable(config[CONF_SDL_ID]) listener = Lambda( diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 57266f33e2..5ced2edf5a 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import subprocess +from typing import Any import esphome.codegen as cg from esphome.components import display @@ -14,6 +16,7 @@ from esphome.const import ( CONF_Y, PLATFORM_HOST, ) +from esphome.types import ConfigType sdl_ns = cg.esphome_ns.namespace("sdl") Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) @@ -35,7 +38,7 @@ WINDOW_OPTIONS = ( SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000 -def get_sdl_options(value): +def get_sdl_options(value: str) -> str: if value != "": return value try: @@ -46,7 +49,7 @@ def get_sdl_options(value): raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e -def get_window_options(): +def get_window_options() -> dict[cv.Optional, Callable[[Any], Any]]: return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS} @@ -100,7 +103,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: for option in config[CONF_SDL_OPTIONS].split(): cg.add_build_flag(option) cg.add_build_flag("-DSDL_BYTEORDER=4321") diff --git a/esphome/components/sdl/touchscreen/__init__.py b/esphome/components/sdl/touchscreen/__init__.py index 9f84f91c72..d7af8da403 100644 --- a/esphome/components/sdl/touchscreen/__init__.py +++ b/esphome/components/sdl/touchscreen/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import touchscreen import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from ..display import CONF_SDL_ID, Sdl, sdl_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SDL_ID]) await touchscreen.register_touchscreen(var, config) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index f71239d18c..56630f18f4 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["uart"] # is the code owner of the relevant code base @@ -43,7 +44,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( # The async def keyword is used to define a concurrent function. # Concurrent functions are special functions designed to work with Python's asyncio library to support asynchronous I/O operations. -async def to_code(config): +async def to_code(config: ConfigType) -> None: # This line of code creates a new Pvariable (a Python object representing a C++ variable) with the variable's ID taken from the configuration. var = cg.new_Pvariable(config[CONF_ID]) # This line of code registers the newly created Pvariable as a component so that ESPHome can manage it at runtime. diff --git a/esphome/components/seeed_mr24hpc1/binary_sensor.py b/esphome/components/seeed_mr24hpc1/binary_sensor.py index 26de1e4ac1..121eb2b4b3 100644 --- a/esphome/components/seeed_mr24hpc1/binary_sensor.py +++ b/esphome/components/seeed_mr24hpc1/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -13,7 +14,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button/__init__.py index 1e68d7e071..3386118bcf 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if restart_config := config.get(CONF_RESTART): b = await button.new_button(restart_config) diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number/__init__.py index 4de3654e39..d01618b0e6 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -63,7 +64,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if sensitivity_config := config.get(CONF_SENSITIVITY): n = await number.new_number( diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select/__init__.py index 14854f0795..9d46dee6f6 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -38,7 +39,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if scenemode_config := config.get(CONF_SCENE_MODE): s = await select.new_select( diff --git a/esphome/components/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index ca15fd5be6..36ee2c0087 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if custompresenceofdetection_config := config.get( CONF_CUSTOM_PRESENCE_OF_DETECTION diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch/__init__.py index 741e7de3ca..f9588d783e 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_MR24HPC1_ID, MR24HPC1Component, mr24hpc1_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if underlying_open_function_config := config.get(CONF_UNDERLYING_OPEN_FUNCTION): s = await switch.new_switch(underlying_open_function_config) diff --git a/esphome/components/seeed_mr24hpc1/text_sensor.py b/esphome/components/seeed_mr24hpc1/text_sensor.py index fadd9c6dbc..8f284cb20a 100644 --- a/esphome/components/seeed_mr24hpc1/text_sensor.py +++ b/esphome/components/seeed_mr24hpc1/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import CONF_MR24HPC1_ID, MR24HPC1Component @@ -47,7 +48,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) if heartbeat_config := config.get(CONF_HEART_BEAT): sens = await text_sensor.new_text_sensor(heartbeat_config) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 87bdbbd003..6bf8657af9 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/seeed_mr60bha2/binary_sensor.py b/esphome/components/seeed_mr60bha2/binary_sensor.py index 99940ebf6d..4130bac224 100644 --- a/esphome/components/seeed_mr60bha2/binary_sensor.py +++ b/esphome/components/seeed_mr60bha2/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_MR60BHA2_ID, MR60BHA2Component @@ -15,7 +16,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) if has_target_config := config.get(CONF_HAS_TARGET): diff --git a/esphome/components/seeed_mr60bha2/sensor.py b/esphome/components/seeed_mr60bha2/sensor.py index d7f667d862..a2f41a90a8 100644 --- a/esphome/components/seeed_mr60bha2/sensor.py +++ b/esphome/components/seeed_mr60bha2/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( UNIT_BEATS_PER_MINUTE, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from . import CONF_MR60BHA2_ID, MR60BHA2Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) if breath_rate_config := config.get(CONF_BREATH_RATE): sens = await sensor.new_sensor(breath_rate_config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index e79134deec..de6e8ad57b 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/seeed_mr60fda2/binary_sensor.py b/esphome/components/seeed_mr60fda2/binary_sensor.py index 2860ac0100..63bd02acd0 100644 --- a/esphome/components/seeed_mr60fda2/binary_sensor.py +++ b/esphome/components/seeed_mr60fda2/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_SAFETY +from esphome.types import ConfigType from . import CONF_MR60FDA2_ID, MR60FDA2Component @@ -21,7 +22,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if people_exist_config := config.get(CONF_PEOPLE_EXIST): diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button/__init__.py index 8236248b8c..82f0fc9aea 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ENTITY_CATEGORY_NONE, ) +from esphome.types import ConfigType from .. import CONF_MR60FDA2_ID, MR60FDA2Component, mr60fda2_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if get_radar_parameters_config := config.get(CONF_GET_RADAR_PARAMETERS): b = await button.new_button(get_radar_parameters_config) diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select/__init__.py index 2fea150cd2..6d8864455f 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG, ICON_ACCELERATION_Z +from esphome.types import ConfigType from .. import CONF_MR60FDA2_ID, MR60FDA2Component, mr60fda2_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) if install_height_config := config.get(CONF_INSTALL_HEIGHT): s = await select.new_select( diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 7f6492812f..16d7ef8e86 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -41,6 +43,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from .init_sequences import ST7701S_INITS, cmd @@ -58,7 +61,7 @@ COLOR_ORDERS = { DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -73,14 +76,14 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.Schema: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def map_sequence(value): +def map_sequence(value: Any) -> list: """ An initialisation sequence can be selected from one of the pre-defined sequences in init_sequences.py, or can be a literal array of data bytes. @@ -170,7 +173,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/st7701s/init_sequences.py b/esphome/components/st7701s/init_sequences.py index 4786731c78..a67f3f63fb 100644 --- a/esphome/components/st7701s/init_sequences.py +++ b/esphome/components/st7701s/init_sequences.py @@ -1,7 +1,7 @@ # These are initialisation sequences for ST7701S displays. The contents are somewhat arcane. -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 5dfd188f0f..a782d875b9 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any, NoReturn + from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg @@ -13,7 +16,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID from esphome.core import ID -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -45,8 +48,8 @@ UDP_SCHEMA = cv.Schema( ) -def is_relocated(option): - def validator(value): +def is_relocated(option: str) -> Callable[[Any], NoReturn]: + def validator(value: Any) -> NoReturn: raise cv.Invalid( f"The '{option}' option should now be configured in the 'packet_transport' component" ) @@ -109,13 +112,13 @@ CONFIG_SCHEMA = cv.All( ) -async def register_udp_client(var, config): +async def register_udp_client(var: MockObj, config: ConfigType) -> MockObj: udp_var = await cg.get_variable(config[CONF_UDP_ID]) cg.add(var.set_parent(udp_var)) return udp_var -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_UDP") cg.add_global(udp_ns.using) var = cg.new_Pvariable(config[CONF_ID]) @@ -147,7 +150,7 @@ async def to_code(config): cg.add(var.set_should_listen()) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, str): @@ -171,7 +174,12 @@ def validate_raw_data(value): ), synchronous=True, ) -async def udp_write_to_code(config, action_id, template_arg, args): +async def udp_write_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) udp_var = await cg.get_variable(config[CONF_ID]) await cg.register_parented(var, udp_var) diff --git a/esphome/components/udp/packet_transport/__init__.py b/esphome/components/udp/packet_transport/__init__.py index e725276717..f2c15289a9 100644 --- a/esphome/components/udp/packet_transport/__init__.py +++ b/esphome/components/udp/packet_transport/__init__.py @@ -7,6 +7,7 @@ from esphome.components.packet_transport import ( ) from esphome.const import CONF_BINARY_SENSORS, CONF_ENCRYPTION, CONF_SENSORS from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import UDP_SCHEMA, register_udp_client, udp_ns @@ -15,7 +16,7 @@ UDPTransport = udp_ns.class_("UDPTransport", PacketTransport, PollingComponent) CONFIG_SCHEMA = transport_schema(UDPTransport).extend(UDP_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var, providers = await new_packet_transport(config) udp_var = await register_udp_client(var, config) if CONF_ENCRYPTION in config or providers: diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a921b6fbf0..edbf75f70f 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -18,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.cpp_types import Component +from esphome.types import ConfigType AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] CODEOWNERS = ["@clydebarrow"] @@ -48,14 +49,14 @@ DEFAULT_BAUD_RATE = 9600 class Type: def __init__( self, - name, - vid, - pid, - cls, - max_channels=1, - baud_rate_required=True, - max_baud=1_000_000, - ): + name: str, + vid: int, + pid: int, + cls: str | None, + max_channels: int = 1, + baud_rate_required: bool = True, + max_baud: int = 1_000_000, + ) -> None: self.name = name cls = cls or name self.vid = vid @@ -156,7 +157,7 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: list[ConfigType]) -> None: # The output chunk pool/queue are compile-time-sized templates shared by all # USBUartChannel instances, so use the largest buffer_size across every channel # of every device. Add one extra slot because LockFreeQueue is a ring From fbe4b39a165d7e2bd54a448c020f4640d2809dbe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:58:58 +1200 Subject: [PATCH 301/597] [core] Add type annotations to component Python (3/11) (#18340) --- .../components/copy/binary_sensor/__init__.py | 3 +- esphome/components/copy/button/__init__.py | 3 +- esphome/components/copy/cover/__init__.py | 3 +- esphome/components/copy/fan/__init__.py | 3 +- esphome/components/copy/lock/__init__.py | 3 +- esphome/components/copy/number/__init__.py | 3 +- esphome/components/copy/select/__init__.py | 3 +- esphome/components/copy/sensor/__init__.py | 3 +- esphome/components/copy/switch/__init__.py | 3 +- esphome/components/copy/text/__init__.py | 3 +- .../components/copy/text_sensor/__init__.py | 3 +- esphome/components/integration/sensor.py | 23 +++++++++--- esphome/components/key_collector/__init__.py | 19 +++++++--- .../key_collector/text_sensor/__init__.py | 4 +-- esphome/components/ledc/output.py | 20 ++++++++--- esphome/components/matrix_keypad/__init__.py | 5 +-- .../matrix_keypad/binary_sensor/__init__.py | 5 +-- esphome/components/pid/climate.py | 26 +++++++++++--- esphome/components/pid/sensor/__init__.py | 3 +- esphome/components/rp2/__init__.py | 15 ++++---- esphome/components/rp2/generate_boards.py | 2 +- esphome/components/rp2/gpio.py | 16 +++++---- esphome/components/rp2040_pwm/output.py | 12 +++++-- esphome/components/sn74hc165/__init__.py | 12 ++++--- esphome/components/sun/__init__.py | 24 ++++++++++--- esphome/components/sun/sensor/__init__.py | 3 +- .../components/sun/text_sensor/__init__.py | 5 +-- esphome/components/touchscreen/__init__.py | 22 ++++++++---- .../touchscreen/binary_sensor/__init__.py | 5 +-- esphome/components/update/__init__.py | 34 ++++++++++++------ esphome/components/vbus/__init__.py | 3 +- .../components/vbus/binary_sensor/__init__.py | 3 +- esphome/components/vbus/sensor/__init__.py | 3 +- .../components/voice_assistant/__init__.py | 35 +++++++++++++++---- .../components/xiaomi_rtcgq02lm/__init__.py | 3 +- .../xiaomi_rtcgq02lm/binary_sensor.py | 3 +- esphome/components/xiaomi_rtcgq02lm/sensor.py | 3 +- 37 files changed, 246 insertions(+), 95 deletions(-) diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index 840200409f..cc8492f21e 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 8028d6a217..768131bbe5 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -32,7 +33,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await button.register_button(var, config) await cg.register_component(var, config) diff --git a/esphome/components/copy/cover/__init__.py b/esphome/components/copy/cover/__init__.py index ff5bef5668..d23602fa74 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/fan/__init__.py b/esphome/components/copy/fan/__init__.py index a208e5f80a..ffa414c5f2 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -3,6 +3,7 @@ from esphome.components import fan import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 46bc08273e..8d9c4b6eca 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -3,6 +3,7 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index 3e2bbf2aae..9659a605f9 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index d7ddc52c44..97776b1edd 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -3,6 +3,7 @@ from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await select.register_select(var, config, options=[]) await cg.register_component(var, config) diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 57ca06aca7..5468798047 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -37,7 +38,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index ee27e38c5f..0e714540f9 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py index f1ca404b7b..59fdce6c96 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_MODE, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -26,7 +27,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 7b38ff1a64..146beae5ea 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 8d784df672..82e8ba8df8 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( @@ -39,14 +42,14 @@ CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" -def inherit_unit_of_measurement(uom, config): +def inherit_unit_of_measurement(uom: str, config: ConfigType) -> str: suffix = config[CONF_TIME_UNIT] if uom.endswith("/" + suffix): return uom[0 : -len("/" + suffix)] return uom + suffix -def inherit_accuracy_decimals(decimals, config): +def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int: return decimals + 2 @@ -90,7 +93,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -113,7 +116,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_integration_reset_to_code(config, action_id, template_arg, args): +async def sensor_integration_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -130,7 +138,12 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args ), synchronous=True, ) -async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): +async def sensor_integration_set_value_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 1f4519df2d..bf47b6df88 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,8 +15,9 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TRIGGER_ID, ) +from esphome.core import ID from esphome.cpp_generator import MockObj, literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@ssieb"] @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): @@ -144,7 +145,12 @@ async def to_code(config): ), synchronous=True, ) -async def enable_to_code(config, action_id, template_arg, args): +async def enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -160,7 +166,12 @@ async def enable_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def disable_to_code(config, action_id, template_arg, args): +async def disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/key_collector/text_sensor/__init__.py b/esphome/components/key_collector/text_sensor/__init__.py index 1676cf7bdf..e32d15df2e 100644 --- a/esphome/components/key_collector/text_sensor/__init__.py +++ b/esphome/components/key_collector/text_sensor/__init__.py @@ -4,7 +4,7 @@ from esphome.components.text_sensor import TextSensor import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.cpp_generator import literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType from .. import CONF_ON_RESULT, CONF_SOURCE_ID, TRIGGER_TYPES, KeyCollector @@ -15,7 +15,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SOURCE_ID]) var = cg.new_Pvariable(config[CONF_ID]) await text_sensor.register_text_sensor(var, config) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 95df1fba23..637e607b6d 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import output @@ -9,20 +11,23 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] -def calc_max_frequency(bit_depth): +def calc_max_frequency(bit_depth: int) -> float: return 80e6 / (2**bit_depth) -def calc_min_frequency(bit_depth): +def calc_min_frequency(bit_depth: int) -> float: max_div_num = ((2**20) - 1) / 256.0 return 80e6 / (max_div_num * (2**bit_depth)) -def validate_frequency(value): +def validate_frequency(value: Any) -> float: value = cv.frequency(value) min_freq = calc_min_frequency(20) max_freq = calc_max_frequency(1) @@ -56,7 +61,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -79,7 +84,12 @@ async def to_code(config): ), synchronous=True, ) -async def ledc_set_frequency_to_code(config, action_id, template_arg, args): +async def ledc_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 868b149211..47cf4793b1 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -4,6 +4,7 @@ from esphome.components import key_provider from esphome.components.const import CONF_ROWS import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -27,7 +28,7 @@ CONF_HAS_DIODES = "has_diodes" CONF_HAS_PULLDOWNS = "has_pulldowns" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -62,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) row_pins = [] diff --git a/esphome/components/matrix_keypad/binary_sensor/__init__.py b/esphome/components/matrix_keypad/binary_sensor/__init__.py index 8e63ed43ce..6c6e0aad73 100644 --- a/esphome/components/matrix_keypad/binary_sensor/__init__.py +++ b/esphome/components/matrix_keypad/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_ROW +from esphome.types import ConfigType from .. import CONF_KEYPAD_ID, MatrixKeypad, matrix_keypad_ns @@ -12,7 +13,7 @@ MatrixKeypadBinarySensor = matrix_keypad_ns.class_( ) -def check_button(obj): +def check_button(obj: ConfigType) -> ConfigType: if CONF_ROW in obj or CONF_COL in obj: if CONF_KEY in obj: raise cv.Invalid("You can't provide both a key and a position") @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_KEY in config: var = cg.new_Pvariable(config[CONF_ID], config[CONF_KEY][0]) else: diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 3e4ff754c9..4945547f2e 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) @@ -82,7 +85,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) @@ -141,7 +144,12 @@ async def to_code(config): ), synchronous=True, ) -async def pid_reset_integral_term(config, action_id, template_arg, args): +async def pid_reset_integral_term( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -163,7 +171,12 @@ async def pid_reset_integral_term(config, action_id, template_arg, args): ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) cg.add(var.set_noiseband(config[CONF_NOISEBAND])) @@ -185,7 +198,12 @@ async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_control_parameters(config, action_id, template_arg, args): +async def set_control_parameters( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index d26e88e38a..94d641de47 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import sensor from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.types import ConfigType from ..climate import PIDClimate, pid_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_CLIMATE_ID]) var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 60fcd4f8b0..ed975ec01a 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -34,6 +34,7 @@ from esphome.core import ( from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from . import boards @@ -145,7 +146,7 @@ def only_on_variant( return validator_ -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built RP2040 firmware. Used by device-builder (esphome/device-builder), via @@ -181,7 +182,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: return f"https://github.com/earlephilhower/arduino-pico/releases/download/{ver}/rp2040-{ver}.zip" -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: value = cv.string(value) if value.startswith("http"): return value @@ -205,7 +206,7 @@ RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), @@ -316,7 +317,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor @@ -588,7 +589,7 @@ def _generate_lwipopts_h() -> None: write_file_if_changed(lwip_dir / "lwipopts.h", content) -def add_pio_file(component: str, key: str, data: str): +def add_pio_file(component: str, key: str, data: str) -> None: try: cv.validate_id_name(key) except cv.Invalid as e: @@ -629,7 +630,7 @@ def generate_pio_files() -> bool: # Called by writer.py -def copy_files(): +def copy_files() -> None: dir = Path(__file__).parent post_build_file = dir / "post_build.py.script" copy_file_if_changed( @@ -670,7 +671,7 @@ def _addr2line(tool: str, elf: Path, addr: str) -> str: return f"{addr} (decode failed)" -def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: """Decode RP2040 crash handler output using addr2line.""" if _CRASH_RE.search(line): _LOGGER.error("RP2040 crash detected - decoding addresses") diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index cd3f50182c..4066ef6b34 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -256,7 +256,7 @@ def generate(arduino_pico_path: Path) -> str: return result.stdout.decode() -def main(): +def main() -> None: if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) diff --git a/esphome/components/rp2/gpio.py b/esphome/components/rp2/gpio.py index e4db6a831c..d325131178 100644 --- a/esphome/components/rp2/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv @@ -14,6 +16,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_RP2, rp2_ns @@ -21,7 +25,7 @@ from .const import KEY_BOARD, KEY_RP2, rp2_ns RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_RP2][KEY_BOARD] board_pins = boards.RP2_BOARD_PINS.get(board, {}) @@ -35,7 +39,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -54,12 +58,12 @@ def _translate_pin(value): return _lookup_pin(value) -def _board_max_virtual_pin(board): +def _board_max_virtual_pin(board: str) -> int | None: """Get the max CYW43 virtual pin for this board, or None if no virtual pins.""" return boards.BOARDS.get(board, {}).get("max_virtual_pin") -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) @@ -71,7 +75,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None @@ -100,7 +104,7 @@ RP2_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) -async def rp2_pin_to_code(config): +async def rp2_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a2fda58c9e..a0344e8054 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["rp2"] @@ -22,7 +25,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) @@ -44,7 +47,12 @@ async def to_code(config): ), synchronous=True, ) -async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): +async def rp2040_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index f2ba5fedd1..4f21312fec 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = [] @@ -38,7 +40,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) @@ -54,7 +56,7 @@ async def to_code(config): cg.add(var.set_sr_count(config[CONF_SR_COUNT])) -def _validate_input_mode(value): +def _validate_input_mode(value: bool) -> bool: if value is not True: raise cv.Invalid("Only input mode is supported") return value @@ -77,7 +79,9 @@ SN74HC165_PIN_SCHEMA = cv.All( ) -def sn74hc165_pin_final_validate(pin_config, parent_config): +def sn74hc165_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: max_pins = parent_config[CONF_SR_COUNT] * 8 if pin_config[CONF_NUMBER] >= max_pins: raise cv.Invalid(f"Pin number must be less than {max_pins}") @@ -86,7 +90,7 @@ def sn74hc165_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_SN74HC165, SN74HC165_PIN_SCHEMA, sn74hc165_pin_final_validate ) -async def sn74hc165_pin_to_code(config): +async def sn74hc165_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SN74HC165]) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index c065a82958..33a5c677bd 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -1,5 +1,6 @@ import contextlib import re +from typing import Any from esphome import automation import esphome.codegen as cg @@ -12,6 +13,9 @@ from esphome.const import ( CONF_TIME_ID, CONF_TRIGGER_ID, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] sun_ns = cg.esphome_ns.namespace("sun") @@ -40,7 +44,7 @@ ELEVATION_MAP = { } -def elevation(value): +def elevation(value: Any) -> float: if isinstance(value, str): with contextlib.suppress(cv.Invalid): value = ELEVATION_MAP[ @@ -60,7 +64,7 @@ LAT_LON_REGEX = re.compile( ) -def parse_latlon(value): +def parse_latlon(value: Any) -> float: if isinstance(value, str) and value.endswith("°"): # strip trailing degree character value = value[:-1] @@ -114,7 +118,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) time_ = await cg.get_variable(config[CONF_TIME_ID]) cg.add(var.set_time(time_)) @@ -150,7 +154,12 @@ async def to_code(config): } ), ) -async def sun_above_horizon_to_code(config, condition_id, template_arg, args): +async def sun_above_horizon_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) @@ -171,7 +180,12 @@ async def sun_above_horizon_to_code(config, condition_id, template_arg, args): } ), ) -async def sun_below_horizon_to_code(config, condition_id, template_arg, args): +async def sun_below_horizon_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_ELEVATION], args, cg.double) diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index a1ced8ff5b..d2e9fa750d 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DEGREES, ) +from esphome.types import ConfigType from .. import CONF_SUN_ID, Sun, sun_ns @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/sun/text_sensor/__init__.py b/esphome/components/sun/text_sensor/__init__.py index fc733d3435..523471bd41 100644 --- a/esphome/components/sun/text_sensor/__init__.py +++ b/esphome/components/sun/text_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_WEATHER_SUNSET_DOWN, ICON_WEATHER_SUNSET_UP, ) +from esphome.types import ConfigType from .. import CONF_ELEVATION, CONF_SUN_ID, DEFAULT_ELEVATION, Sun, elevation, sun_ns @@ -22,7 +23,7 @@ SUN_TYPES = { } -def validate_optional_icon(config): +def validate_optional_icon(config: ConfigType) -> ConfigType: if CONF_ICON not in config: config = config.copy() config[CONF_ICON] = { @@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index cf0c5fca19..c8b918007b 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -1,3 +1,7 @@ +from typing import Any + +import voluptuous as vol + from esphome import automation import esphome.codegen as cg from esphome.components import display @@ -14,6 +18,8 @@ from esphome.const import ( CONF_TRANSFORM, ) from esphome.core import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@nielsnl68"] DEPENDENCIES = ["display"] @@ -40,7 +46,7 @@ CONF_Y_MIN = "y_min" CONF_Y_MAX = "y_max" -def validate_calibration(calibration_config): +def validate_calibration(calibration_config: ConfigType) -> ConfigType: x_min = calibration_config[CONF_X_MIN] x_max = calibration_config[CONF_X_MAX] y_min = calibration_config[CONF_Y_MIN] @@ -60,7 +66,9 @@ def validate_calibration(calibration_config): return calibration_config -def option_with_default(option: str, defaults: dict, required: bool = False): +def option_with_default( + option: str, defaults: dict, required: bool = False +) -> vol.Marker: if option in defaults or not required: return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) return cv.Required(option) @@ -119,9 +127,9 @@ def _transform_schema(defaults: dict) -> dict: def touchscreen_schema( - default_touch_timeout=cv.UNDEFINED, - calibration_required=False, - defaults: dict = None, + default_touch_timeout: Any = cv.UNDEFINED, + calibration_required: bool = False, + defaults: dict | None = None, ) -> cv.Schema: defaults = defaults or {} return cv.Schema( @@ -143,7 +151,7 @@ def touchscreen_schema( TOUCHSCREEN_SCHEMA = touchscreen_schema(cv.UNDEFINED) -async def register_touchscreen(var, config): +async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) disp = await cg.get_variable(config[CONF_DISPLAY]) @@ -192,6 +200,6 @@ async def register_touchscreen(var, config): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(touchscreen_ns.using) cg.add_define("USE_TOUCHSCREEN") diff --git a/esphome/components/touchscreen/binary_sensor/__init__.py b/esphome/components/touchscreen/binary_sensor/__init__.py index 5ce0defb31..6a66d00ea6 100644 --- a/esphome/components/touchscreen/binary_sensor/__init__.py +++ b/esphome/components/touchscreen/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor, display import esphome.config_validation as cv from esphome.const import CONF_PAGE_ID, CONF_PAGES +from esphome.types import ConfigType from .. import CONF_TOUCHSCREEN_ID, TouchListener, Touchscreen, touchscreen_ns @@ -22,7 +23,7 @@ CONF_Y_MAX = "y_max" CONF_USE_RAW = "use_raw" -def _validate_coords(config): +def _validate_coords(config: ConfigType) -> ConfigType: if ( config[CONF_X_MAX] < config[CONF_X_MIN] or config[CONF_Y_MAX] < config[CONF_Y_MIN] @@ -66,7 +67,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_TOUCHSCREEN_ID]) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 18d333a5ef..5ebe58881d 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,14 +14,15 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] IS_PLATFORM_COMPONENT = True @@ -95,7 +96,7 @@ def update_schema( @setup_entity("update") -async def setup_update_core_(var, config): +async def setup_update_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if on_update_available := config.get(CONF_ON_UPDATE_AVAILABLE): @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_update(var, config): +async def register_update(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("update", config) @@ -121,14 +122,14 @@ async def register_update(var, config): await setup_update_core_(var, config) -async def new_update(config): +async def new_update(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_update(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(update_ns.using) @@ -145,7 +146,12 @@ async def to_code(config): ), synchronous=True, ) -async def update_perform_action_to_code(config, action_id, template_arg, args): +async def update_perform_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -164,7 +170,12 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def update_check_action_to_code(config, action_id, template_arg, args): +async def update_check_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -180,8 +191,11 @@ async def update_check_action_to_code(config, action_id, template_arg, args): ), ) async def update_is_available_condition_to_code( - config, condition_id, template_arg, args -): + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 2663496456..94857050f2 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -29,7 +30,7 @@ CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 85f1172166..5c09a025f8 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC, ) +from esphome.types import ConfigType from .. import ( CONF_DELTASOL_BS2, @@ -256,7 +257,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index 9c3665eb1c..e8a6ea7bfa 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import ( CONF_DELTASOL_BS2, @@ -650,7 +651,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index f41adfd8de..d30eaf4768 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -14,6 +14,9 @@ from esphome.const import ( CONF_ON_START, CONF_SPEAKER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio", "ring_buffer", "socket"] DEPENDENCIES = ["api", "microphone"] @@ -78,7 +81,7 @@ ConnectedCondition = voice_assistant_ns.class_( Timer = voice_assistant_ns.struct("Timer") -def tts_stream_validate(config): +def tts_stream_validate(config: ConfigType) -> ConfigType: if CONF_SPEAKER not in config and ( CONF_ON_TTS_STREAM_START in config or CONF_ON_TTS_STREAM_END in config ): @@ -199,7 +202,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -420,7 +423,12 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis ), synchronous=True, ) -async def voice_assistant_listen_to_code(config, action_id, template_arg, args): +async def voice_assistant_listen_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) if CONF_SILENCE_DETECTION in config: @@ -434,7 +442,12 @@ async def voice_assistant_listen_to_code(config, action_id, template_arg, args): @register_action( "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True ) -async def voice_assistant_stop_to_code(config, action_id, template_arg, args): +async def voice_assistant_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -443,7 +456,12 @@ async def voice_assistant_stop_to_code(config, action_id, template_arg, args): @register_condition( "voice_assistant.is_running", IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA ) -async def voice_assistant_is_running_to_code(config, condition_id, template_arg, args): +async def voice_assistant_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -452,7 +470,12 @@ async def voice_assistant_is_running_to_code(config, condition_id, template_arg, @register_condition( "voice_assistant.connected", ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA ) -async def voice_assistant_connected_to_code(config, condition_id, template_arg, args): +async def voice_assistant_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 3e235d985f..7b289a8ee3 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS +from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 8d0508b59b..57420125cb 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType from . import XiaomiRTCGQ02LM @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if CONF_MOTION in config: diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e49f1c960b..e0e4b4640b 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import XiaomiRTCGQ02LM @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if CONF_BATTERY_LEVEL in config: From b6a9761dae4b015a30d9dd2492c408ff3ea424b1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:59:51 +1200 Subject: [PATCH 302/597] [core] Add type annotations to component Python (4/11) (#18341) --- esphome/components/aic3204/audio_dac.py | 12 +++++- esphome/components/audio_adc/__init__.py | 13 ++++-- esphome/components/audio_dac/__init__.py | 20 ++++++++-- esphome/components/bme68x_bsec2/__init__.py | 7 ++-- esphome/components/bme68x_bsec2/sensor.py | 6 ++- .../components/bme68x_bsec2/text_sensor.py | 6 ++- .../components/dfrobot_sen0395/__init__.py | 23 +++++++++-- .../dfrobot_sen0395/binary_sensor.py | 3 +- .../dfrobot_sen0395/switch/__init__.py | 3 +- esphome/components/dlms_meter/__init__.py | 14 ++++--- .../dlms_meter/binary_sensor/__init__.py | 3 +- .../components/dlms_meter/sensor/__init__.py | 5 ++- .../dlms_meter/text_sensor/__init__.py | 5 ++- esphome/components/ina2xx_base/__init__.py | 11 +++-- esphome/components/logger/__init__.py | 30 +++++++++----- esphome/components/logger/select/__init__.py | 3 +- esphome/components/ltr501/sensor.py | 13 +++--- esphome/components/ltr_als_ps/sensor.py | 11 +++-- esphome/components/msa3xx/__init__.py | 3 +- esphome/components/msa3xx/binary_sensor.py | 3 +- esphome/components/msa3xx/sensor.py | 3 +- esphome/components/msa3xx/text_sensor.py | 6 ++- esphome/components/ota/__init__.py | 10 +++-- esphome/components/safe_mode/__init__.py | 16 +++++--- .../components/safe_mode/button/__init__.py | 3 +- .../components/safe_mode/switch/__init__.py | 3 +- esphome/components/spi/__init__.py | 40 ++++++++++--------- esphome/components/st7789v/display.py | 10 +++-- esphome/components/substitutions/jinja.py | 12 +++--- esphome/components/thermostat/climate.py | 18 ++++++--- .../waveshare_io_ch32v003/__init__.py | 8 ++-- .../waveshare_io_ch32v003/output/__init__.py | 5 ++- .../waveshare_io_ch32v003/sensor/__init__.py | 3 +- esphome/components/web_server/__init__.py | 12 +++--- esphome/components/web_server/ota/__init__.py | 2 +- 35 files changed, 229 insertions(+), 116 deletions(-) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b478b573a3..50e2f81f1b 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,6 +4,9 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( SET_AUTO_MUTE_ACTION_SCHEMA, synchronous=True, ) -async def aic3204_set_volume_to_code(config, action_id, template_arg, args): +async def aic3204_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c3a4988b5..c2bdfb6cb0 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -2,7 +2,9 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -28,7 +30,12 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( SET_MIC_GAIN_ACTION_SCHEMA, synchronous=True, ) -async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): +async def audio_adc_set_mic_gain_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -39,6 +46,6 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_ADC") cg.add_global(audio_adc_ns.using) diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 46c277ce51..1351793afd 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -3,7 +3,9 @@ from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -37,7 +39,12 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True ) -async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): +async def audio_dac_mute_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -48,7 +55,12 @@ async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): SET_VOLUME_ACTION_SCHEMA, synchronous=True, ) -async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): +async def audio_dac_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -59,6 +71,6 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_DAC") cg.add_global(audio_dac_ns.using) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index c12eb39d2d..8208672b6a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.cpp_generator import MockObj from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -94,7 +95,7 @@ def _compute_url(config: dict) -> str: return f"https://raw.githubusercontent.com/boschsensortec/Bosch-BSEC2-Library/{BSEC2_LIBRARY_VERSION}/src/config/{model}/{model}_{algo}_{volts}_{sample_rate}_{operating_age}/{filename}.txt" -def download_bme68x_blob(config): +def download_bme68x_blob(config: ConfigType) -> ConfigType: url = _compute_url(config) path = _compute_local_file_path(url) external_files.download_content(url, path) @@ -138,7 +139,7 @@ def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) -def validate_bme68x(config): +def validate_bme68x(config: ConfigType) -> ConfigType: if CONF_ALGORITHM_OUTPUT not in config: return config @@ -178,7 +179,7 @@ CONFIG_SCHEMA_BASE = ( ) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index 52587dba99..863cd9d601 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -29,6 +29,8 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component @@ -119,7 +121,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await sensor.new_sensor(conf) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -127,7 +129,7 @@ async def setup_conf(config, key, hub): cg.add(getattr(hub, f"set_{key}_sample_rate")(sample_rate)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme68x_bsec2/text_sensor.py b/esphome/components/bme68x_bsec2/text_sensor.py index fce00afe34..5c6f9f696c 100644 --- a/esphome/components/bme68x_bsec2/text_sensor.py +++ b/esphome/components/bme68x_bsec2/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, BME68xBSEC2Component @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await text_sensor.new_text_sensor(conf) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 943c510279..51562f923c 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_SENSITIVITY +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@niklasweber"] DEPENDENCIES = ["uart"] @@ -38,7 +43,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -54,14 +59,19 @@ async def to_code(config): ), synchronous=True, ) -async def dfrobot_sen0395_reset_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -def range_segment_list(input): +def range_segment_list(input: Any) -> list: """Validate input is a list of ranges which can be used to configure the dfrobot mmwave radar A list of segments should be provided. A minimum of one segment is required and a maximum of @@ -154,7 +164,12 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema( MMWAVE_SETTINGS_SCHEMA, synchronous=True, ) -async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/dfrobot_sen0395/binary_sensor.py b/esphome/components/dfrobot_sen0395/binary_sensor.py index 193ef925a4..e299c35a42 100644 --- a/esphome/components/dfrobot_sen0395/binary_sensor.py +++ b/esphome/components/dfrobot_sen0395/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_MOTION +from esphome.types import ConfigType from . import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -16,7 +17,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) binary_sens = await binary_sensor.new_binary_sensor(config) diff --git a/esphome/components/dfrobot_sen0395/switch/__init__.py b/esphome/components/dfrobot_sen0395/switch/__init__.py index 8e492080de..22aaa1640c 100644 --- a/esphome/components/dfrobot_sen0395/switch/__init__.py +++ b/esphome/components/dfrobot_sen0395/switch/__init__.py @@ -3,6 +3,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType from .. import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index b747f73a14..00a1694cc3 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,5 +1,6 @@ import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -33,13 +35,13 @@ DlmsMeterComponent = dlms_meter_component_ns.class_( ) -def obis_code(value): +def obis_code(value: Any) -> str: # Normalize the OBIS code to the strict A.B.C.D.E.F format bytes_list = parse_obis_code_bytes(value) return ".".join(str(b) for b in bytes_list) -def parse_obis_code_bytes(value): +def parse_obis_code_bytes(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -57,19 +59,19 @@ def parse_obis_code_bytes(value): return bytes_list -def custom_pattern_dict(value): +def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(value): +def validate_custom_pattern(value: ConfigType) -> ConfigType: if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") return value -def validate_provider_deprecation(config): +def validate_provider_deprecation(config: ConfigType) -> ConfigType: if CONF_PROVIDER in config: provider = str(config[CONF_PROVIDER]).lower() if provider == "netznoe": @@ -154,7 +156,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: dec_key_expr = cg.RawExpression("std::nullopt") if dec_key := config.get(CONF_DECRYPTION_KEY): key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index f9bc1d9df7..a15e58b957 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -14,7 +15,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await binary_sensor.new_binary_sensor(config) cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index ec4639351d..8ded150cd0 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -47,7 +48,7 @@ DYNAMIC_SCHEMA = sensor.sensor_schema().extend( ) -def deprecation_warning(config): +def deprecation_warning(config: ConfigType) -> ConfigType: _LOGGER.warning( "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " "Please update your configuration to use the new schema with 'obis_code'." @@ -145,7 +146,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 0bfb43a285..c2ff0779ee 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -3,6 +3,7 @@ import logging import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -23,7 +24,7 @@ DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( ) -def deprecation_warning(config): +def deprecation_warning(config: ConfigType) -> ConfigType: _LOGGER.warning( "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " "Please update your configuration to use the new schema with 'obis_code'." @@ -46,7 +47,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 15e2faba07..7bb589f0b1 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_AMPERE_HOUR @@ -26,6 +28,9 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import EnumValue +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -76,7 +81,7 @@ SENSOR_MODEL_OPTIONS = { } -def validate_model_config(config): +def validate_model_config(config: ConfigType) -> ConfigType: model = config[CONF_MODEL] for key in config: @@ -92,7 +97,7 @@ def validate_model_config(config): return config -def validate_adc_time(value): +def validate_adc_time(value: Any) -> EnumValue: value = cv.positive_time_period_microseconds(value).total_microseconds return cv.enum(ADC_TIMES, int=True)(value) @@ -198,7 +203,7 @@ INA2XX_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def setup_ina2xx(var, config): +async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index f307f5d5d1..07b8b03084 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,4 +1,5 @@ import re +from typing import Any from esphome import automation from esphome.automation import LambdaAction, StatelessLambdaAction @@ -58,7 +59,8 @@ from esphome.const import ( PLATFORM_RTL87XX, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -164,7 +166,7 @@ HARDWARE_UART_TO_SERIAL = { is_log_level = cv.one_of(*LOG_LEVELS, upper=True) -def uart_selection(value): +def uart_selection(value: Any) -> str: if CORE.is_esp32: variant = get_esp32_variant() if variant in UART_SELECTION_ESP32: @@ -187,7 +189,7 @@ def uart_selection(value): raise NotImplementedError -def validate_local_no_higher_than_global(config): +def validate_local_no_higher_than_global(config: ConfigType) -> ConfigType: global_level = config[CONF_LEVEL] global_level_index = LOG_LEVEL_SEVERITY.index(global_level) errs = [] @@ -204,7 +206,7 @@ def validate_local_no_higher_than_global(config): return config -def validate_initial_no_higher_than_global(config): +def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: if initial_level := config.get(CONF_INITIAL_LEVEL): global_level = config[CONF_LEVEL] if LOG_LEVEL_SEVERITY.index(initial_level) > LOG_LEVEL_SEVERITY.index( @@ -217,7 +219,7 @@ def validate_initial_no_higher_than_global(config): return config -def validate_wait_for_cdc(config): +def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") return config @@ -518,7 +520,7 @@ async def _late_logger_init(config: ConfigType) -> None: CORE.add_job(final_step) -def validate_printf(value): +def validate_printf(value: ConfigType) -> ConfigType: # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" ( # start of capture group 1 @@ -559,7 +561,12 @@ LOGGER_LOG_ACTION_SCHEMA = cv.All( @automation.register_action( CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, synchronous=True ) -async def logger_log_action_to_code(config, action_id, template_arg, args): +async def logger_log_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: esp_log = LOG_LEVEL_TO_ESP_LOG[config[CONF_LEVEL]] args_ = [cg.RawExpression(str(x)) for x in config[CONF_ARGS]] @@ -584,7 +591,12 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def logger_set_level_to_code(config, action_id, template_arg, args): +async def logger_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: level = LOG_LEVELS[config[CONF_LEVEL]] logger = await cg.get_variable(config[CONF_LOGGER_ID]) if tag := config.get(CONF_TAG): @@ -656,7 +668,7 @@ def request_log_listener() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional logger features.""" domain_data = CORE.data.get(DOMAIN, {}) if domain_data.get(KEY_LEVEL_LISTENERS, False): diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 6ce663978e..00f67422f3 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented +from esphome.types import ConfigType from .. import ( CONF_LOGGER_ID, @@ -26,7 +27,7 @@ CONFIG_SCHEMA = select.select_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c1fa9009b3..c2091a6336 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -87,17 +90,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -107,7 +110,7 @@ def validate_time_and_repeat_rate(config): return config -def validate_als_gain_and_integration_time(config): +def validate_als_gain_and_integration_time(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] if config[CONF_GAIN] == "1X" and integraton_time > 100: raise cv.Invalid( @@ -221,7 +224,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 893415f028..af09282e2d 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -23,6 +25,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -93,17 +96,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -211,7 +214,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 04514b584f..0beece6710 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TRANSFORM, CONF_TYPE, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -123,7 +124,7 @@ MSA_SENSOR_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index 732a0ed291..ef27c98e66 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -31,7 +32,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for sensor in EVENT_SENSORS: diff --git a/esphome/components/msa3xx/sensor.py b/esphome/components/msa3xx/sensor.py index 63f050fa05..22bcb94025 100644 --- a/esphome/components/msa3xx/sensor.py +++ b/esphome/components/msa3xx/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER_PER_SECOND_SQUARED, ) +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -34,7 +35,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for accel_key in ACCELERATION_SENSORS: if accel_key in config: diff --git a/esphome/components/msa3xx/text_sensor.py b/esphome/components/msa3xx/text_sensor.py index c53a4aa139..6693ec8542 100644 --- a/esphome/components/msa3xx/text_sensor.py +++ b/esphome/components/msa3xx/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_NAME +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA @@ -25,13 +27,13 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_MSA3XX_ID]) for key in ORIENTATION_SENSORS: diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 1e2ee947c1..5240db9e8f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType OTA_STATE_LISTENER_KEY = "ota_state_listener" @@ -49,7 +51,7 @@ OTAStateChangeTrigger = ota_ns.class_( ) -def _ota_final_validate(config): +def _ota_final_validate(config: ConfigType) -> None: if len(config) < 1: raise cv.Invalid( f"At least one platform must be specified for '{CONF_OTA}'; add '{CONF_PLATFORM}: {CONF_ESPHOME}' for original OTA functionality" @@ -95,7 +97,7 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA") CORE.add_job(final_step) @@ -103,7 +105,7 @@ async def to_code(config): cg.add_library("Updater", None) -async def ota_to_code(var, config): +async def ota_to_code(var: MockObj, config: ConfigType) -> None: await cg.past_safe_mode() use_state_callback = False for conf in config.get(CONF_ON_STATE_CHANGE, []): @@ -145,7 +147,7 @@ def request_ota_state_listeners() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional OTA features.""" if CORE.data.get(OTA_STATE_LISTENER_KEY, False): cg.add_define("USE_OTA_STATE_LISTENER") diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 70096a56bc..9bc8a263c8 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,8 +10,9 @@ from esphome.const import ( CONF_STORAGE, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import RawExpression +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -24,7 +25,7 @@ SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) -def _remove_id_if_disabled(value): +def _remove_id_if_disabled(value: ConfigType) -> ConfigType: value = value.copy() if value[CONF_DISABLED]: value.pop(CONF_ID) @@ -62,7 +63,12 @@ CONFIG_SCHEMA = cv.All( ), synchronous=True, ) -async def safe_mode_mark_successful_to_code(config, action_id, template_arg, args): +async def safe_mode_mark_successful_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg) cg.add(var.set_parent(parent)) @@ -75,7 +81,7 @@ _CALLBACK_AUTOMATIONS = ( @coroutine_with_priority(CoroPriority.APPLICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if not config[CONF_DISABLED]: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index 0731ca50f5..89e2475799 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import SafeModeComponent, safe_mode_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch/__init__.py index d656eee84a..529b023d68 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_SAFE_MODE, ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import SafeModeComponent, safe_mode_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 608adc7514..d7b85ee20d 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -83,7 +83,7 @@ def _render_hz(value: float) -> str: return formatted + unit -def _frequency_validator(value): +def _frequency_validator(value: Any) -> float: platform = get_target_platform() frequency = PLATFORM_SPI_CLOCKS[platform] value = cv.frequency(value) @@ -153,17 +153,17 @@ RP_SPI_PINSETS = [ ] -def get_target_platform(): +def get_target_platform() -> str: return CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] -def get_target_variant(): +def get_target_variant() -> str: return CORE.data[KEY_ESP32].get(KEY_VARIANT, "") # Get a list of available hardware interfaces based on target and variant. # The returned value is a list of lists of names -def get_hw_interface_list(): +def get_hw_interface_list() -> list[list[str]]: target_platform = get_target_platform() if target_platform == PLATFORM_ESP8266: return [["spi", "hspi"]] @@ -196,7 +196,7 @@ def one_of_interface_validator(additional_values: list[str] | None = None) -> An if additional_values is None: additional_values = [] - def validator(value: str) -> str: + def validator(value: Any) -> str: return cv.one_of( *sum(get_hw_interface_list(), additional_values), lower=True, @@ -206,7 +206,7 @@ def one_of_interface_validator(additional_values: list[str] | None = None) -> An # Given an SPI name, return the index of it in the available list -def get_spi_index(name): +def get_spi_index(name: str) -> int: for i, ilist in enumerate(get_hw_interface_list()): if name in ilist: return i @@ -218,7 +218,7 @@ def get_spi_index(name): # \param spi the config data for the spi instance # \param index the selected hw interface number, -1 if not yet known # TODO verify that the pins are internal -def validate_hw_pins(spi, index=-1): +def validate_hw_pins(spi: ConfigType, index: int = -1) -> bool: clk_pin = spi[CONF_CLK_PIN] if clk_pin[CONF_INVERTED]: return False @@ -265,7 +265,7 @@ def validate_hw_pins(spi, index=-1): return False -def get_hw_spi(config, available): +def get_hw_spi(config: ConfigType, available: list[int]) -> int | None: """Get an available hardware spi interface suitable for this config""" matching = list(filter(lambda idx: validate_hw_pins(config, idx), available)) if len(matching) != 0: @@ -273,7 +273,7 @@ def get_hw_spi(config, available): return None -def validate_spi_config(config): +def validate_spi_config(config: list[ConfigType]) -> list[ConfigType]: available = list(range(len(get_hw_interface_list()))) for spi in config: interface = spi[CONF_INTERFACE] @@ -317,7 +317,7 @@ def validate_spi_config(config): # Given an SPI index, convert to a string that represents the C++ object for it. -def get_spi_interface(index): +def get_spi_interface(index: int) -> str: platform = get_target_platform() if platform == PLATFORM_ESP32: # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks @@ -353,7 +353,7 @@ SPI_SINGLE_SCHEMA = cv.All( ) -def spi_mode_schema(mode): +def spi_mode_schema(mode: str) -> cv.Schema: if mode == TYPE_SINGLE: return SPI_SINGLE_SCHEMA pin_count = 4 if mode == TYPE_QUAD else 8 @@ -400,7 +400,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.BUS) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: cg.add_define("USE_SPI") cg.add_global(spi_ns.using) if CORE.using_arduino and not CORE.is_esp32: @@ -427,11 +427,11 @@ async def to_code(configs): def spi_device_schema( - cs_pin_required=True, - default_data_rate=cv.UNDEFINED, - default_mode=cv.UNDEFINED, - mode=TYPE_SINGLE, -): + cs_pin_required: bool = True, + default_data_rate: Any = cv.UNDEFINED, + default_mode: Any = cv.UNDEFINED, + mode: str = TYPE_SINGLE, +) -> cv.Schema: """Create a schema for an SPI device. :param cs_pin_required: If true, make the CS_PIN required in the config. :param default_data_rate: Optional data_rate to use as default @@ -456,7 +456,7 @@ def spi_device_schema( async def register_spi_device( - var: cg.Pvariable, config: ConfigType, write_only: bool = False + var: cg.MockObj, config: ConfigType, write_only: bool = False ) -> None: parent = await cg.get_variable(config[CONF_SPI_ID]) cg.add(var.set_spi_parent(parent)) @@ -473,7 +473,9 @@ async def register_spi_device( cg.add(var.set_release_device(release_device)) -def final_validate_device_schema(name: str, *, require_mosi: bool, require_miso: bool): +def final_validate_device_schema( + name: str, *, require_mosi: bool, require_miso: bool +) -> cv.Schema: hub_schema = {} if require_miso: hub_schema[ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 3b4d6d99ea..fa72c7c328 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -19,6 +20,7 @@ from esphome.const import ( CONF_ROTATION, CONF_WIDTH, ) +from esphome.types import ConfigType from . import st7789v_ns @@ -38,7 +40,9 @@ MODEL_PRESETS = "model_presets" REQUIRE_PS = "require_ps" -def model_spec(require_ps=False, presets=None): +def model_spec( + require_ps: bool = False, presets: dict[str, Any] | None = None +) -> dict[str, Any]: if presets is None: presets = {} return {MODEL_PRESETS: presets, REQUIRE_PS: require_ps} @@ -119,7 +123,7 @@ MODELS = { } -def validate_st7789v(config): +def validate_st7789v(config: ConfigType) -> ConfigType: model_data = MODELS[config[CONF_MODEL]] presets = model_data[MODEL_PRESETS] for key, value in presets.items(): @@ -178,7 +182,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." ) diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index 36a7425a69..230ad09df9 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -43,23 +43,23 @@ SAFE_GLOBALS = { class JinjaError(Exception): - def __init__(self, context_trace: dict, expr: str): + def __init__(self, context_trace: dict, expr: str) -> None: self.context_trace = context_trace self.eval_stack = [expr] - def parent(self): + def parent(self) -> BaseException | None: return self.__context__ - def error_name(self): + def error_name(self) -> str: return type(self.parent()).__name__ - def context_trace_str(self): + def context_trace_str(self) -> str: return "\n".join( f" {k} = {repr(v)} ({type(v).__name__})" for k, v in self.context_trace.items() ) - def stack_trace_str(self): + def stack_trace_str(self) -> str: return "\n".join( f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}" for i, expr in enumerate(self.eval_stack) @@ -67,7 +67,7 @@ class JinjaError(Exception): class TrackerContext(jinja.runtime.Context): - def resolve_or_missing(self, key): + def resolve_or_missing(self, key: str) -> Any: val = super().resolve_or_missing(key) if val is Missing: # Variable not in the template context — check if a resolver callback diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index d609e22ac2..3cc4dc7009 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import climate, sensor @@ -70,6 +72,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION = "humidity_control_dehumidify_action" @@ -124,7 +127,12 @@ PRESET_CONFIG_SCHEMA = cv.Schema( ) -def validate_temperature_preset(preset, root_config, name, requirements): +def validate_temperature_preset( + preset: ConfigType, + root_config: ConfigType, + name: str, + requirements: dict[str, list[str]], +) -> None: # verify temperature settings for the provided preset / default / away configuration for config_temp, req_actions in requirements.items(): for req_action in req_actions: @@ -140,7 +148,7 @@ def validate_temperature_preset(preset, root_config, name, requirements): ) -def generate_comparable_preset(config, name): +def generate_comparable_preset(config: ConfigType, name: str) -> str: comparable_preset = f"{CONF_PRESET}:\n - {CONF_NAME}: {name}\n" if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: @@ -151,7 +159,7 @@ def generate_comparable_preset(config, name): return comparable_preset -def validate_heat_cool_mode(value) -> list: +def validate_heat_cool_mode(value: Any) -> list: """Validate heat_cool_mode - accepts either True or an automation.""" if value is True: # Convert True to empty automation list @@ -164,7 +172,7 @@ def validate_heat_cool_mode(value) -> list: return automation.validate_automation(single=True)(value) -def validate_thermostat(config): +def validate_thermostat(config: ConfigType) -> ConfigType: # verify corresponding action(s) exist(s) for any defined climate mode or action requirements = { CONF_HEAT_COOL_MODE: [ @@ -681,7 +689,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index b692b858a3..29a939c523 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -41,13 +43,13 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -71,7 +73,7 @@ WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) -async def waveshare_io_pin_to_code(config): +async def waveshare_io_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py index 9af9ce7e4b..7438769928 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE +from esphome.types import ConfigType from .. import ( CONF_WAVESHARE_IO_CH32V003_ID, @@ -23,7 +24,7 @@ DUTY_DEFAULT_MIN = 1 DUTY_DEFAULT_MAX = 247 -def validate_pwm_limits(config): +def validate_pwm_limits(config: ConfigType) -> ConfigType: """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py index 1e060bdfe4..8ec2702da6 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import ( CONF_WAVESHARE_IO_CH32V003_ID, @@ -46,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) await cg.register_component(var, config) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index b2c0ea14ad..a50c14a2f7 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -4,6 +4,7 @@ import base64 import gzip import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import web_server_base @@ -39,6 +40,7 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -128,7 +130,7 @@ def validate_ota(config: ConfigType) -> ConfigType: _ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") -def validate_origin(value: str) -> str: +def validate_origin(value: Any) -> str: # "*" is the wildcard that allows any origin. if value == "*": return value @@ -306,7 +308,7 @@ CONFIG_SCHEMA = cv.All( ) -def add_sorting_groups(web_server_var, config): +def add_sorting_groups(web_server_var: MockObj, config: list[ConfigType]) -> None: for group in config: sorting_groups[group[CONF_ID]] = group[CONF_NAME] group_sorting_weight = group.get(CONF_SORTING_WEIGHT, 50) @@ -317,7 +319,7 @@ def add_sorting_groups(web_server_var, config): ) -async def add_entity_config(entity, config): +async def add_entity_config(entity: MockObj, config: ConfigType) -> None: web_server = await cg.get_variable(config[CONF_WEB_SERVER_ID]) sorting_weight = config.get(CONF_SORTING_WEIGHT, 50) sorting_group_hash = hash(config.get(CONF_SORTING_GROUP_ID)) @@ -332,7 +334,7 @@ async def add_entity_config(entity, config): ) -def build_index_html(config) -> str: +def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) js_include = config.get(CONF_JS_INCLUDE) @@ -366,7 +368,7 @@ def add_resource_as_progmem( @coroutine_with_priority(CoroPriority.WEB) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 260e6aea6d..03a5c2ca9b 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -80,7 +80,7 @@ FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) From e83439eaaeed12653926473ff9fbfcbc168254f2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:07:10 +1200 Subject: [PATCH 303/597] [core] Add type annotations to component Python (7/11) (#18344) --- esphome/components/as5600/__init__.py | 24 ++++++----- esphome/components/as5600/sensor/__init__.py | 3 +- esphome/components/audio/__init__.py | 17 ++++---- esphome/components/duty_time/sensor.py | 40 ++++++++++++++++--- esphome/components/esp32_hosted/__init__.py | 10 ++--- esphome/components/mixer/speaker/__init__.py | 16 ++++++-- esphome/components/rc522/__init__.py | 4 +- esphome/components/rc522/binary_sensor.py | 7 +++- .../components/resampler/speaker/__init__.py | 11 +++-- esphome/components/rtttl/__init__.py | 28 ++++++++++--- esphome/components/scd4x/sensor.py | 19 +++++++-- esphome/components/sen5x/sensor.py | 13 +++++- esphome/components/sendspin/__init__.py | 6 +-- .../components/sendspin/sensor/__init__.py | 4 +- esphome/components/sound_level/sensor.py | 12 +++++- esphome/components/sps30/sensor.py | 12 +++++- esphome/components/sx127x/__init__.py | 24 ++++++++--- .../sx127x/packet_transport/__init__.py | 3 +- esphome/components/tm1651/__init__.py | 40 ++++++++++++++++--- esphome/components/ufire_ec/sensor.py | 19 +++++++-- esphome/components/ufire_ise/sensor.py | 26 ++++++++++-- 21 files changed, 261 insertions(+), 77 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index c05e556376..780712c3bd 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c @@ -11,6 +14,7 @@ from esphome.const import ( CONF_RANGE, CONF_WATCHDOG, ) +from esphome.types import ConfigType CODEOWNERS = ["@ammmze"] DEPENDENCIES = ["i2c"] @@ -72,13 +76,13 @@ POSITION_TO_ANGLE = 360 / RESOLUTION MIN_RANGE = round(18 * ANGLE_TO_POSITION) -def angle(min=-360, max=360): +def angle(min: float = -360, max: float = 360) -> Callable[[Any], Any]: return cv.All( cv.float_with_unit("angle", "(°|deg)"), cv.float_range(min=min, max=max) ) -def angle_to_position(value, min=-360, max=360): +def angle_to_position(value: Any, min: float = -360, max: float = 360) -> int: try: value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION @@ -86,17 +90,17 @@ def angle_to_position(value, min=-360, max=360): raise cv.Invalid(f"When using angle, {e.error_message}") from e -def percent_to_position(value): +def percent_to_position(value: Any) -> int: value = cv.possibly_negative_percentage(value) return (RESOLUTION + round(value * RESOLUTION)) % RESOLUTION -def position(min=-MAX_POSITION, max=MAX_POSITION): +def position(min: int = -MAX_POSITION, max: int = MAX_POSITION) -> Callable[[Any], Any]: """Validate that the config option is a position. Accepts integers, degrees, or percentage (of 360 degrees). """ - def validator(value): + def validator(value: Any) -> int: if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) @@ -112,7 +116,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): return validator -def position_range(): +def position_range() -> Callable[[Any], Any]: """Validate that value given is a valid range for the device. A valid range is one of the following: - a value of 0 (meaning full range) @@ -129,7 +133,7 @@ def position_range(): zero_validator, ) - def validator(value): + def validator(value: Any) -> Any: is_negative_str = isinstance(value, str) and value.startswith("-") is_negative_num = isinstance(value, (float, int)) and value < 0 if is_negative_str or is_negative_num: @@ -139,13 +143,13 @@ def position_range(): return validator -def has_valid_range_config(): +def has_valid_range_config() -> Callable[[ConfigType], ConfigType]: """Validate that that the config start + end position results in a valid positional range, which must be >= 18degrees """ range_validator = position_range() - def validator(config): + def validator(config: ConfigType) -> ConfigType: # if we don't have an end position, then there is nothing to do if CONF_END_POSITION not in config: return config @@ -203,7 +207,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index cf67a3f203..847b89f121 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import AS5600Component, as5600_ns @@ -77,7 +78,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_AS5600_ID]) await cg.register_component(var, config) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1c522cbb5d..277df0506a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any import esphome.codegen as cg from esphome.components.esp32 import ( @@ -15,6 +17,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["ring_buffer"] CODEOWNERS = ["@kahrendt"] @@ -125,10 +128,10 @@ CONF_THREADSAFE = "threadsafe" _MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True) -def _maybe_empty_codec(schema): +def _maybe_empty_codec(schema: cv.Schema) -> Callable[[Any], Any]: """Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict.""" - def validator(value): + def validator(value: Any) -> Any: if value is None: value = {} return schema(value) @@ -200,14 +203,14 @@ def set_stream_limits( max_channels: int = cv.UNDEFINED, min_sample_rate: int = cv.UNDEFINED, max_sample_rate: int = cv.UNDEFINED, -): +) -> Callable[[ConfigType], None]: """Sets the limits for the audio stream that audio component can handle When the component sinks audio (e.g., a speaker), these indicate the limits to the audio it can receive. When the component sources audio (e.g., a microphone), these indicate the limits to the audio it can send. """ - def set_limits_in_config(config): + def set_limits_in_config(config: ConfigType) -> None: if min_bits_per_sample is not cv.UNDEFINED: config[CONF_MIN_BITS_PER_SAMPLE] = min_bits_per_sample if max_bits_per_sample is not cv.UNDEFINED: @@ -233,7 +236,7 @@ def final_validate_audio_schema( sample_rate: int = cv.UNDEFINED, enabled_channels: list[int] = cv.UNDEFINED, audio_device_issue: bool = False, -): +) -> cv.Schema: """Validates audio compatibility when passed between different components. The component derived from ``AUDIO_COMPONENT_SCHEMA`` should call ``set_stream_limits`` in a validator to specify its compatible settings @@ -251,7 +254,7 @@ def final_validate_audio_schema( audio_device_issue (bool, optional): Format the error message to indicate the problem is in the configuration for the ``audio_device`` component. Defaults to False. """ - def validate_audio_compatiblity(audio_config): + def validate_audio_compatiblity(audio_config: ConfigType) -> ConfigType: audio_schema = {} if bits_per_sample is not cv.UNDEFINED: @@ -329,7 +332,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N add_idf_sdkconfig_option(internal_key, True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) include_builtin_idf_component("esp_http_client") diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 456859f8e4..6d878a80a5 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -19,6 +19,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_LAST_TIME = "last_time" @@ -66,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) @@ -93,7 +96,12 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( @register_action( "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_start_to_code(config, action_id, template_arg, args): +async def sensor_runtime_start_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -102,7 +110,12 @@ async def sensor_runtime_start_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): +async def sensor_runtime_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -111,7 +124,12 @@ async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): +async def sensor_runtime_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -120,7 +138,12 @@ async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): @register_condition( "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @@ -128,6 +151,11 @@ async def duty_time_is_running_to_code(config, condition_id, template_arg, args) @register_condition( "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_not_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_not_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7dc61ce382..ab9455250c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -64,7 +64,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_sdio(config): +def _validate_sdio(config: ConfigType) -> ConfigType: if config[CONF_BUS_WIDTH] == 4: for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN): if pin not in config: @@ -98,7 +98,7 @@ SPI_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_spi(config): +def _validate_spi(config: ConfigType) -> ConfigType: variant = config[CONF_VARIANT] defaults = _SPI_VARIANT_DEFAULTS.get(variant, _SPI_DEFAULT) @@ -141,7 +141,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -def _configure_sdio(config): +def _configure_sdio(config: ConfigType) -> None: slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", @@ -183,7 +183,7 @@ def _configure_sdio(config): ) -def _configure_spi(config): +def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) # SPI mode is set via per-variant choice options variant = config[CONF_VARIANT] @@ -231,7 +231,7 @@ def _configure_spi(config): esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: add_define("USE_ESP32_HOSTED") transport = config[CONF_TYPE] transport_prefix = "SDIO" if transport == "sdio" else "SPI" diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 47164a9997..a3746c019a 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,8 +15,11 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -48,7 +51,7 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( ) -def _validate_source_speaker(config): +def _validate_source_speaker(config: ConfigType) -> ConfigType: fconf = fv.full_config.get() # Get ID for the output speaker and add it to the source speakers config to easily inherit properties @@ -70,7 +73,7 @@ def _validate_source_speaker(config): return config -def _validate_output_speaker(config): +def _validate_output_speaker(config: ConfigType) -> ConfigType: audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, @@ -112,7 +115,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -161,7 +164,12 @@ async def to_code(config): ), synchronous=True, ) -async def ducking_set_to_code(config, action_id, template_arg, args): +async def ducking_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) decibel_reduction = await cg.templatable( diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index ce0d408c04..e9e8dd7b73 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -8,6 +8,8 @@ from esphome.const import ( CONF_RESET_PIN, CONF_TRIGGER_ID, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] AUTO_LOAD = ["binary_sensor"] @@ -38,7 +40,7 @@ RC522_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -async def setup_rc522(var, config): +async def setup_rc522(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if CONF_RESET_PIN in config: diff --git a/esphome/components/rc522/binary_sensor.py b/esphome/components/rc522/binary_sensor.py index 87f81c2223..f295b75df7 100644 --- a/esphome/components/rc522/binary_sensor.py +++ b/esphome/components/rc522/binary_sensor.py @@ -1,15 +1,18 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_UID from esphome.core import HexInt +from esphome.types import ConfigType from . import CONF_RC522_ID, RC522, rc522_ns DEPENDENCIES = ["rc522"] -def validate_uid(value): +def validate_uid(value: Any) -> str: value = cv.string_strict(value) for x in value.split("-"): if len(x) != 2: @@ -39,7 +42,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(RC522BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_RC522_ID]) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index ea080adc6b..7de468cb50 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import audio, psram, speaker import esphome.config_validation as cv @@ -13,6 +15,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -27,7 +30,7 @@ CONF_TAPS = "taps" PASSTHROUGH = "passthrough" -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=16, max_bits_per_sample=32, @@ -36,7 +39,7 @@ def _set_stream_limits(config): return config -def _validate_audio_compatibility(config): +def _validate_audio_compatibility(config: ConfigType) -> None: inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) @@ -57,7 +60,7 @@ def _validate_audio_compatibility(config): )(config) -def _validate_taps(taps): +def _validate_taps(taps: Any) -> int: value = cv.int_range(min=16, max=128)(taps) if value % 4 != 0: raise cv.Invalid("Number of taps must be divisible by 4") @@ -88,7 +91,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = _validate_audio_compatibility -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await speaker.register_speaker(var, config) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4880f9ac41..b6c4183586 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -6,7 +6,10 @@ from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -37,7 +40,7 @@ CONFIG_SCHEMA = cv.All( ) -def validate_parent_output_config(value): +def validate_parent_output_config(value: ConfigType) -> None: platform = value.get(CONF_PLATFORM) PWM_GOOD = ["esp8266_pwm", "ledc"] PWM_BAD = [ @@ -78,7 +81,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -110,7 +113,12 @@ async def to_code(config): ), synchronous=True, ) -async def rtttl_play_to_code(config, action_id, template_arg, args): +async def rtttl_play_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_RTTTL], args, cg.std_string) @@ -128,7 +136,12 @@ async def rtttl_play_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def rtttl_stop_to_code(config, action_id, template_arg, args): +async def rtttl_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -143,7 +156,12 @@ async def rtttl_stop_to_code(config, action_id, template_arg, args): } ), ) -async def rtttl_is_playing_to_code(config, condition_id, template_arg, args): +async def rtttl_is_playing_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 6f14118660..af3ff3a7af 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -26,6 +26,9 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@sjtrny", "@martgras"] DEPENDENCIES = ["i2c"] @@ -108,7 +111,7 @@ SETTING_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -143,7 +146,12 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( SCD4X_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_frc_to_code(config, action_id, template_arg, args): +async def scd4x_frc_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) @@ -164,7 +172,12 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( SCD4X_RESET_ACTION_SCHEMA, synchronous=True, ) -async def scd4x_reset_to_code(config, action_id, template_arg, args): +async def scd4x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 761a1885ea..e86c8bf899 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -41,6 +43,8 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -115,7 +119,7 @@ def _gas_sensor( ) -def float_previously_pct(value): +def float_previously_pct(value: Any) -> Any: if isinstance(value, str) and "%" in value: raise cv.Invalid( f"The value '{value}' is a percentage. Suggested value: {float(value.strip('%')) / 100}" @@ -284,6 +288,11 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( SEN5X_ACTION_SCHEMA, synchronous=True, ) -async def sen54_fan_to_code(config, action_id, template_arg, args): +async def sen54_fan_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 082639374f..570fd3fadd 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import CORE, ID -from esphome.cpp_generator import TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType # mdns for autodiscovery @@ -219,7 +219,7 @@ async def sendspin_switch_to_code( action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -297,7 +297,7 @@ async def to_code(config: ConfigType) -> None: codecs.append(CODEC_FORMAT_OPUS) codecs.append(CODEC_FORMAT_PCM) - def _audio_format(codec, channels): + def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( AudioSupportedFormatObject, ("codec", codec), diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py index dc9b86c2a3..d6016ed91d 100644 --- a/esphome/components/sendspin/sensor/__init__.py +++ b/esphome/components/sendspin/sensor/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -50,7 +52,7 @@ def _request_roles(config: ConfigType) -> ConfigType: _HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) -def _metadata_schema(**sensor_kwargs): +def _metadata_schema(**sensor_kwargs: Any) -> cv.Schema: """Schema for event-driven numeric metadata sensors (duration/year/track).""" return ( sensor.sensor_schema( diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index 44f31979b4..d217534041 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -11,6 +11,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DECIBEL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -95,7 +98,12 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True ) -async def sound_level_action_to_code(config, action_id, template_arg, args): +async def sound_level_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 40557f2cbd..681166cd3c 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,6 +26,9 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_MICROMETER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@martgras"] DEPENDENCIES = ["i2c"] @@ -120,7 +123,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -197,7 +200,12 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( SPS30_ACTION_SCHEMA, synchronous=True, ) -async def sps30_action_to_code(config, action_id, template_arg, args): +async def sps30_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 8fa7247192..34f2d4122f 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi @@ -5,6 +7,8 @@ from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] @@ -136,7 +140,7 @@ SetModeStandbyAction = sx127x_ns.class_( ) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -146,7 +150,7 @@ def validate_raw_data(value): ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_MODULATION] == "LORA": bws = [ "7_8kHz", @@ -230,7 +234,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -312,7 +316,12 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def no_args_action_to_code(config, action_id, template_arg, args): +async def no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -333,7 +342,12 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( SEND_PACKET_ACTION_SCHEMA, synchronous=True, ) -async def send_packet_action_to_code(config, action_id, template_arg, args): +async def send_packet_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_DATA] diff --git a/esphome/components/sx127x/packet_transport/__init__.py b/esphome/components/sx127x/packet_transport/__init__.py index 2f3a0f6e2b..33204a7d83 100644 --- a/esphome/components/sx127x/packet_transport/__init__.py +++ b/esphome/components/sx127x/packet_transport/__init__.py @@ -6,6 +6,7 @@ from esphome.components.packet_transport import ( ) import esphome.config_validation as cv from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import CONF_SX127X_ID, SX127x, SX127xListener, sx127x_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = transport_schema(SX127xTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) sx127x = await cg.get_variable(config[CONF_SX127X_ID]) cg.add(var.set_parent(sx127x)) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 7d957df3be..c0cc6f1d2c 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,6 +9,9 @@ from esphome.const import ( CONF_ID, CONF_LEVEL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mrtoy-me"] @@ -43,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) @@ -75,7 +78,12 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ), synchronous=True, ) -async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): +async def tm1651_set_brightness_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) @@ -95,7 +103,12 @@ async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def tm1651_set_level_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +128,12 @@ async def tm1651_set_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_percent_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) @@ -129,7 +147,12 @@ async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True, ) -async def output_turn_off_to_code(config, action_id, template_arg, args): +async def output_turn_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -138,7 +161,12 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): @automation.register_action( "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True ) -async def output_turn_on_to_code(config, action_id, template_arg, args): +async def output_turn_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 1d8775ccf0..9d989ad4e6 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MILLISIEMENS_PER_CENTIMETER, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) @@ -99,7 +102,12 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( UFIRE_EC_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): +async def ufire_ec_calibrate_probe_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) solution_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -122,6 +130,11 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( UFIRE_EC_RESET_SCHEMA, synchronous=True, ) -async def ufire_ec_reset_to_code(config, action_id, template_arg, args): +async def ufire_ec_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) 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 23254b2f47..c7e3b6f28d 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PH, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -60,7 +63,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -93,7 +96,12 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): +async def ufire_ise_calibrate_probe_low_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -107,7 +115,12 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, synchronous=True, ) -async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): +async def ufire_ise_calibrate_probe_high_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_SOLUTION], args, cg.float_) @@ -124,6 +137,11 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent UFIRE_ISE_RESET_SCHEMA, synchronous=True, ) -async def ufire_ise_reset_to_code(config, action_id, template_arg, args): +async def ufire_ise_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) From 545f762568609fa7f57e841852308e6c9f2d7dd4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:08:26 +1200 Subject: [PATCH 304/597] [core] Add type annotations to component Python (8/11) (#18345) --- .../components/atm90e32/button/__init__.py | 3 +- .../components/atm90e32/number/__init__.py | 3 +- esphome/components/atm90e32/sensor.py | 3 +- .../atm90e32/text_sensor/__init__.py | 3 +- esphome/components/bl0940/button/__init__.py | 3 +- esphome/components/bl0940/number/__init__.py | 5 +-- esphome/components/bl0940/sensor.py | 19 ++++++----- esphome/components/bm8563/time.py | 26 ++++++++++++--- esphome/components/event/__init__.py | 24 ++++++++++---- esphome/components/ld2410/__init__.py | 12 +++++-- esphome/components/ld2410/binary_sensor.py | 3 +- esphome/components/ld2410/button/__init__.py | 3 +- esphome/components/ld2410/number/__init__.py | 3 +- esphome/components/ld2410/select/__init__.py | 3 +- esphome/components/ld2410/sensor.py | 3 +- esphome/components/ld2410/switch/__init__.py | 3 +- esphome/components/ld2410/text_sensor.py | 3 +- esphome/components/ld2412/__init__.py | 3 +- esphome/components/ld2412/binary_sensor.py | 3 +- esphome/components/ld2412/button/__init__.py | 3 +- esphome/components/ld2412/number/__init__.py | 3 +- esphome/components/ld2412/select/__init__.py | 3 +- esphome/components/ld2412/sensor.py | 3 +- esphome/components/ld2412/switch/__init__.py | 3 +- esphome/components/ld2412/text_sensor.py | 3 +- esphome/components/ld2420/__init__.py | 3 +- .../ld2420/binary_sensor/__init__.py | 3 +- esphome/components/ld2420/button/__init__.py | 3 +- esphome/components/ld2420/number/__init__.py | 3 +- esphome/components/ld2420/select/__init__.py | 3 +- esphome/components/ld2420/sensor/__init__.py | 3 +- .../components/ld2420/text_sensor/__init__.py | 3 +- esphome/components/ld2450/__init__.py | 3 +- esphome/components/ld2450/binary_sensor.py | 3 +- esphome/components/ld2450/button/__init__.py | 3 +- esphome/components/ld2450/number/__init__.py | 3 +- esphome/components/ld2450/select/__init__.py | 3 +- esphome/components/ld2450/sensor.py | 3 +- esphome/components/ld2450/switch/__init__.py | 3 +- esphome/components/ld2450/text_sensor.py | 3 +- esphome/components/max6956/__init__.py | 23 ++++++++++--- esphome/components/max6956/output/__init__.py | 3 +- esphome/components/max7219digit/display.py | 33 ++++++++++++++++--- esphome/components/micronova/__init__.py | 10 ++++-- .../components/micronova/button/__init__.py | 3 +- .../components/micronova/number/__init__.py | 3 +- .../components/micronova/sensor/__init__.py | 3 +- .../components/micronova/switch/__init__.py | 3 +- .../micronova/text_sensor/__init__.py | 3 +- esphome/components/pipsolar/__init__.py | 3 +- .../pipsolar/binary_sensor/__init__.py | 3 +- .../components/pipsolar/output/__init__.py | 12 +++++-- .../components/pipsolar/sensor/__init__.py | 3 +- .../components/pipsolar/switch/__init__.py | 3 +- .../pipsolar/text_sensor/__init__.py | 3 +- esphome/components/text/__init__.py | 30 ++++++++++------- .../components/text/text_sensor/__init__.py | 3 +- 57 files changed, 238 insertions(+), 97 deletions(-) diff --git a/esphome/components/atm90e32/button/__init__.py b/esphome/components/atm90e32/button/__init__.py index 19f62ccfbd..274cce6adb 100644 --- a/esphome/components/atm90e32/button/__init__.py +++ b/esphome/components/atm90e32/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -67,7 +68,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION): diff --git a/esphome/components/atm90e32/number/__init__.py b/esphome/components/atm90e32/number/__init__.py index 848680b875..9c2865dde3 100644 --- a/esphome/components/atm90e32/number/__init__.py +++ b/esphome/components/atm90e32/number/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if voltage_cfg := config.get(CONF_REFERENCE_VOLTAGE): diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index dc46138add..38b24c7cf6 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -41,6 +41,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from . import atm90e32_ns @@ -191,7 +192,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_instance_id(str(config[CONF_ID]))) await cg.register_component(var, config) diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor/__init__.py index ab96f6c207..30585cb873 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C +from esphome.types import ConfigType from ..sensor import ATM90E32Component @@ -34,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if phase_cfg := config.get(CONF_PHASE_STATUS): diff --git a/esphome/components/bl0940/button/__init__.py b/esphome/components/bl0940/button/__init__.py index 04d11e6e30..e87a647392 100644 --- a/esphome/components/bl0940/button/__init__.py +++ b/esphome/components/bl0940/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/number/__init__.py b/esphome/components/bl0940/number/__init__.py index 92ab2837b3..b5a66e682a 100644 --- a/esphome/components/bl0940/number/__init__.py +++ b/esphome/components/bl0940/number/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -27,7 +28,7 @@ CalibrationNumber = bl0940_ns.class_( ) -def validate_min_max(config): +def validate_min_max(config: ConfigType) -> ConfigType: if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: raise cv.Invalid("max_value must be greater than min_value") return config @@ -69,7 +70,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Get the BL0940 component instance bl0940 = await cg.get_variable(config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 96445d5c38..7e6403c3bc 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import bl0940_ns @@ -69,27 +70,29 @@ DEFAULT_BL0940_LEGACY_EREF = 3.6e6 / 297 # methods to calculate voltage and current reference values -def calculate_voltage_reference(vref, r_one, r_two): +def calculate_voltage_reference(vref: float, r_one: float, r_two: float) -> float: # formula: 79931 / Vref * (R1 * 1000) / (R1 + R2) return 79931 / vref * (r_one * 1000) / (r_one + r_two) -def calculate_current_reference(vref, r_shunt): +def calculate_current_reference(vref: float, r_shunt: float) -> float: # formula: 324004 * RL / Vref return 324004 * r_shunt / vref -def calculate_power_reference(voltage_reference, current_reference): +def calculate_power_reference( + voltage_reference: float, current_reference: float +) -> float: # calculate power reference based on voltage and current reference return voltage_reference * current_reference * 4046 / 324004 / 79931 -def calculate_energy_reference(power_reference): +def calculate_energy_reference(power_reference: float) -> float: # formula: power_reference * 3600000 / (1638.4 * 256) return power_reference * 3600000 / (1638.4 * 256) -def validate_legacy_mode(config): +def validate_legacy_mode(config: ConfigType) -> ConfigType: # Only allow schematic calibration options if legacy_mode is False if config.get(CONF_LEGACY_MODE, True): forbidden = [ @@ -106,7 +109,7 @@ def validate_legacy_mode(config): return config -def set_command_defaults(config): +def set_command_defaults(config: ConfigType) -> ConfigType: # Set defaults for read_command and write_command based on legacy_mode legacy = config.get(CONF_LEGACY_MODE, True) if legacy: @@ -118,7 +121,7 @@ def set_command_defaults(config): return config -def set_reference_values(config): +def set_reference_values(config: ConfigType) -> ConfigType: # Set default reference values based on legacy_mode if config.get(CONF_LEGACY_MODE, True): config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF) @@ -223,7 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index ba264f00bf..5ef162bb7c 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -35,7 +38,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def bm8563_write_time_to_code(config, action_id, template_arg, args): +async def bm8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -52,7 +60,12 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_start_timer_to_code(config, action_id, template_arg, args): +async def bm8563_start_timer_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) @@ -70,13 +83,18 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_read_time_to_code(config, action_id, template_arg, args): +async def bm8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index e205e4b910..881107b713 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@nohat"] IS_PLATFORM_COMPONENT = True @@ -93,7 +94,9 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("event") -async def setup_event_core_(var, config, *, event_types: list[str]): +async def setup_event_core_( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_event_types(event_types)) @@ -108,7 +111,9 @@ async def setup_event_core_(var, config, *, event_types: list[str]): await web_server.add_entity_config(var, web_server_config) -async def register_event(var, config, *, event_types: list[str]): +async def register_event( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("event", config) @@ -116,7 +121,7 @@ async def register_event(var, config, *, event_types: list[str]): await setup_event_core_(var, config, event_types=event_types) -async def new_event(config, *, event_types: list[str]): +async def new_event(config: ConfigType, *, event_types: list[str]) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_event(var, config, event_types=event_types) return var @@ -133,7 +138,12 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( @automation.register_action( "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True ) -async def event_fire_to_code(config, action_id, template_arg, args): +async def event_fire_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) @@ -142,5 +152,5 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(event_ns.using) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 360e56330a..19786f38d3 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_THROTTLE, CONF_TIMEOUT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -69,7 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -102,7 +105,12 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( BLUETOOTH_PASSWORD_SET_SCHEMA, synchronous=True, ) -async def bluetooth_password_set_to_code(config, action_id, template_arg, args): +async def bluetooth_password_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_PASSWORD], args, cg.std_string) diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index fb5b5cabff..2b68733532 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -46,7 +47,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index fa6f31ee25..59a9558331 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -44,7 +45,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 01dbcc785d..3500d704a1 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if timeout_config := config.get(CONF_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 9c4f654aa1..e89e3d5997 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): s = await select.new_select( diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 459018e263..ca42b3a1d3 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_CENTIMETER, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -155,7 +156,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if moving_distance_config := config.get(CONF_MOVING_DISTANCE): sens = await sensor.new_sensor(moving_distance_config) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 4276b28a71..6d8053ddd6 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): s = await switch.new_switch(engineering_mode_config) diff --git a/esphome/components/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index a34c8ec0d2..25c61a4825 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index e701d0bda9..82db319861 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@Rihan9"] @@ -40,7 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 98fa5965cd..80cff014c0 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if dynamic_background_correction_status_config := config.get( CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index e0ca285265..5a1ea2e6a5 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index b6e1c8d039..1a81c330ad 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): n = await number.new_number( diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index a54cd700ed..02ecf2c30f 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index f562afe0ee..0b6e676931 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -156,7 +157,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): sens = await sensor.new_sensor(detection_distance_config) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index 7a87e9e483..e7f71222fd 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index 22fba5193e..c8e9f42ef3 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 71a5fa13e4..5a5aabeba0 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 5ebc4a9f63..76b42c0362 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_HAS_TARGET in config: diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py index dfeb121c91..cfcffd0922 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -50,7 +51,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if apply_config := config.get(CONF_APPLY_CONFIG): b = await button.new_button(apply_config) diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index a2637b7b06..448639c911 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_TIMELAPSE, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -113,7 +114,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index b9059c120f..cd66064e47 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if operating_mode_config := config.get(CONF_OPERATING_MODE): sel = await select.new_select( diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index 97acdabd7b..f98d63585b 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_MOVING_DISTANCE in config: diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index 14d982e5fb..cee8f25c1f 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_FW_VERSION in config: diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 585c9f7bf5..4c37f4fcd1 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -49,7 +50,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 89e629253a..779d151fd9 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -39,7 +40,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 682487d750..42cadd2052 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 799c0703f2..4f242076d6 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIMETER, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -78,7 +79,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index 4f237dc94f..d91b42426a 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index ae13900e7a..40462e202d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -226,7 +227,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 0c0c92377b..084f79ee1b 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 4e5d7d419b..a8b978ef48 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_CHIP, ICON_SIGN_DIRECTION, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index e9fae4cceb..5e45d71899 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,6 +11,9 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -54,7 +57,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -62,7 +65,7 @@ async def to_code(config): cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -87,7 +90,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) -async def max6956_pin_to_code(config): +async def max6956_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MAX6956]) @@ -114,7 +117,12 @@ async def max6956_pin_to_code(config): ), synchronous=True, ) -async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_global_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) @@ -136,7 +144,12 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, ), synchronous=True, ) -async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable( diff --git a/esphome/components/max6956/output/__init__.py b/esphome/components/max6956/output/__init__.py index 352ba04a95..f92bbb762a 100644 --- a/esphome/components/max6956/output/__init__.py +++ b/esphome/components/max6956/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_MAX6956, MAX6956, max6956_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MAX6956]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index df2423b0d0..54711263dd 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,6 +10,9 @@ from esphome.const import ( CONF_NUM_CHIPS, CONF_STATE, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@rspaargaren"] DEPENDENCIES = ["spi"] @@ -84,7 +87,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) @@ -144,7 +147,12 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_invert_to_code(config, action_id, template_arg, args): +async def max7219digit_invert_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -164,7 +172,12 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_visible_to_code(config, action_id, template_arg, args): +async def max7219digit_visible_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -184,7 +197,12 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_reverse_to_code(config, action_id, template_arg, args): +async def max7219digit_reverse_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -209,7 +227,12 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( MAX7219_INTENSITY_SCHEMA, synchronous=True, ) -async def max7219digit_intensity_to_code(config, action_id, template_arg, args): +async def max7219digit_intensity_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index b462352229..ff06d0b913 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -7,6 +7,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jorre05", "@edenhaus"] @@ -63,7 +65,7 @@ def MICRONOVA_ADDRESS_SCHEMA( default_memory_location: int | None = None, default_memory_address: int | None = None, is_polling_component: bool, -): +) -> cv.Schema: location_key = ( cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) if default_memory_location is not None @@ -91,7 +93,9 @@ def register_micronova_writer() -> None: _get_data().has_writer = True -async def to_code_micronova_listener(mv, var, config): +async def to_code_micronova_listener( + mv: MockObj, var: MockObj, config: ConfigType +) -> None: _get_data().listener_count += 1 await cg.register_component(var, config) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) @@ -100,7 +104,7 @@ async def to_code_micronova_listener(mv, var, config): cg.add(mv.register_micronova_listener(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 63b127e63d..68b5b9aca6 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MEMORY_ADDRESS, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index bcc972c5a9..d33bb150ce 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_STEP, DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -56,7 +57,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index e53c49aca5..6091718d65 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -125,7 +126,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) for key, divisor in { diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index e149ee3ce3..1f57497ad7 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 33d0779eae..d6b94c437f 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_state_config := config.get(CONF_STOVE_STATE): diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index e3966aa2cc..b404409145 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andreashergert1984"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pipsolar/binary_sensor/__init__.py b/esphome/components/pipsolar/binary_sensor/__init__.py index 5bcf1f75ee..62c0ed8538 100644 --- a/esphome/components/pipsolar/binary_sensor/__init__.py +++ b/esphome/components/pipsolar/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -132,7 +133,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: if type in config: diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index d1ea981589..62e6d0f113 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -75,7 +78,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type, (_, command) in TYPES.items(): @@ -100,7 +103,12 @@ async def to_code(config): ), synchronous=True, ) -async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): +async def output_pipsolar_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 88c6566d63..5a697157b7 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT_AMPS, UNIT_WATT, ) +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -325,7 +326,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: diff --git a/esphome/components/pipsolar/switch/__init__.py b/esphome/components/pipsolar/switch/__init__.py index 11dbc91110..2b493eac95 100644 --- a/esphome/components/pipsolar/switch/__init__.py +++ b/esphome/components/pipsolar/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns @@ -36,7 +37,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type, (on, off) in TYPES.items(): diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index 90ce3a7e55..cc7477395b 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA @@ -31,7 +32,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) for type in TYPES: diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 06b5a10892..e010e2c292 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,13 +13,14 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] IS_PLATFORM_COMPONENT = True @@ -90,13 +91,13 @@ def text_schema( @setup_entity("text") async def setup_text_core_( - var, - config, + var: MockObj, + config: ConfigType, *, min_length: int | None, max_length: int | None, pattern: str | None, -): +) -> None: cg.add(var.traits.set_min_length(min_length)) cg.add(var.traits.set_max_length(max_length)) if pattern is not None: @@ -117,13 +118,13 @@ async def setup_text_core_( async def register_text( - var, - config, + var: MockObj, + config: ConfigType, *, min_length: int | None = 0, max_length: int | None = 255, pattern: str | None = None, -): +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("text", config) @@ -134,12 +135,12 @@ async def register_text( async def new_text( - config, + config: ConfigType, *, min_length: int | None = 0, max_length: int | None = 255, pattern: str | None = None, -): +) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_text( var, config, min_length=min_length, max_length=max_length, pattern=pattern @@ -148,7 +149,7 @@ async def new_text( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(text_ns.using) @@ -169,7 +170,12 @@ OPERATION_BASE_SCHEMA = cv.Schema( ), synchronous=True, ) -async def text_set_to_code(config, action_id, template_arg, args): +async def text_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.std_string) diff --git a/esphome/components/text/text_sensor/__init__.py b/esphome/components/text/text_sensor/__init__.py index 5e45f10193..ab0e9bdcdc 100644 --- a/esphome/components/text/text_sensor/__init__.py +++ b/esphome/components/text/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_SOURCE_ID +from esphome.types import ConfigType from .. import Text, text_ns @@ -19,7 +20,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: source = await cg.get_variable(config[CONF_SOURCE_ID]) var = await text_sensor.new_text_sensor(config, source) await cg.register_component(var, config) From 7fe4399b945e242cf07ac8f7aa830976d1c850d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:11:53 -0500 Subject: [PATCH 305/597] [esp32] Grow the default IDF component exclusion list (#18536) --- esphome/components/ac_dimmer/output.py | 6 ++ esphome/components/esp32/__init__.py | 25 ++++++++- esphome/components/http_request/__init__.py | 5 +- esphome/components/i2c/__init__.py | 5 ++ esphome/components/ledc/output.py | 4 ++ esphome/components/mqtt/__init__.py | 2 + esphome/components/nextion/display.py | 2 + esphome/components/web_server_idf/__init__.py | 8 ++- .../esp32/config/exclusion_reincludes.yaml | 20 +++++++ .../exclusion_reincludes_http_request.yaml | 14 +++++ .../config/exclusion_reincludes_mqtt.yaml | 14 +++++ .../config/exclusion_reincludes_nextion.yaml | 20 +++++++ .../exclusion_reincludes_web_server.yaml | 14 +++++ tests/component_tests/esp32/test_esp32.py | 56 +++++++++++++++++++ 14 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml create mode 100644 tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 1f35095e0e..48bef2c317 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -49,6 +49,12 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the gptimer driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_gptimer") + if CORE.is_esp8266: # ac_dimmer uses setTimer1Callback which requires the waveform generator from esphome.components.esp8266.const import require_waveform diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6e0890751..f1f039922a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -204,18 +204,32 @@ COMPILER_OPTIMIZATIONS = { # ESP-IDF components excluded by default to reduce compile time. # Components can be re-enabled by calling include_builtin_idf_component() in to_code(). # -# Cannot be excluded (dependencies of required components): -# - "console": espressif/mdns unconditionally depends on it -# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain +# Note: excluding a component only removes it from the initial build set. +# ESP-IDF's requirement expansion adds an excluded component back when any +# component still in the build REQUIRES it (e.g. espressif/mdns pulls +# "console" back in, esp_http_client pulls "tcp_transport" back in), so +# exclusions here are safe for such components and simply become no-ops in +# builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( + "app_trace", # CPU trace/SystemView support - unused by ESPHome "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers + "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf "esp_adc", # ADC driver - only needed by adc component + "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself "esp_driver_mcpwm", # MCPWM driver - ESPHome doesn't use motor control PWM "esp_driver_pcnt", # PCNT driver - only needed by pulse_counter, hlw8012 components "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus + "esp_driver_sdio", # SDIO device-mode driver - unused by ESPHome + "esp_driver_sdm", # Sigma-delta modulation driver - unused by ESPHome + "esp_driver_sdmmc", # SD/MMC host driver - unused by ESPHome + "esp_driver_sdspi", # SD-over-SPI driver - unused by ESPHome "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component @@ -227,11 +241,16 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "json", # cJSON library - ESPHome uses ArduinoJson instead "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation "openthread", # Thread protocol - only needed by openthread component "perfmon", # Xtensa performance monitor - ESPHome has its own debug component + "protobuf-c", # Protobuf runtime - only used by provisioning components (also excluded) "protocomm", # Protocol communication for provisioning - unused by ESPHome + "rt", # POSIX realtime extensions - unused by ESPHome + "sdmmc", # SD/MMC protocol layer - only used by SD drivers and fatfs (also excluded) "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "tcp_transport", # Transport layer - esp_http_client/mqtt pull it back when re-included "ulp", # ULP coprocessor - not currently used by any ESPHome component "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index afc39e06a8..8a5aae022a 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -170,8 +170,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). + # esp-tls is re-enabled too because http_request includes + # directly and esp_http_client only pulls it in as a private dependency. esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 7b163d065e..94aad4d019 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -284,6 +284,11 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the I2C driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2c") if CORE.is_host: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 637e607b6d..e5e7c3dcbe 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import output +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -62,6 +63,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: + # Re-enable the LEDC driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_ledc") + gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 98ca23b60b..9178bc79e5 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -361,6 +361,8 @@ async def to_code(config): add_idf_component(name="espressif/mqtt", ref="1.0.0") else: include_builtin_idf_component("mqtt") + # mqtt_client.h drags in esp_tls types; esp-tls is excluded by default + include_builtin_idf_component("esp-tls") cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 4ab123c354..3f5ba94b40 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -290,7 +290,9 @@ async def to_code(config): if CORE.is_esp32: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # and esp-tls, whose sdkconfig options below need the component present esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 74a9d657a6..adf21ddc49 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,4 +1,7 @@ -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + include_builtin_idf_component, +) import esphome.config_validation as cv CODEOWNERS = ["@dentra"] @@ -12,3 +15,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + # Re-enable esp-tls (excluded by default to save compile time); + # web_server_idf.cpp includes for digest auth + include_builtin_idf_component("esp-tls") diff --git a/tests/component_tests/esp32/config/exclusion_reincludes.yaml b/tests/component_tests/esp32/config/exclusion_reincludes.yaml new file mode 100644 index 0000000000..ba5bf17688 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + sda: 21 + scl: 22 + +output: + - platform: ledc + id: ledc_out + pin: 25 + - platform: ac_dimmer + id: dimmer_out + gate_pin: 26 + zero_cross_pin: 27 diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml new file mode 100644 index 0000000000..5adfd66b00 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_http_request.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: false diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml new file mode 100644 index 0000000000..c509942635 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_mqtt.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +mqtt: + broker: "10.0.0.1" diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml new file mode 100644 index 0000000000..3c6e527b09 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_nextion.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +display: + - platform: nextion + tft_url: "http://10.0.0.1/display.tft" diff --git a/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml b/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml new file mode 100644 index 0000000000..6041bffee6 --- /dev/null +++ b/tests/component_tests/esp32/config/exclusion_reincludes_web_server.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +web_server: + version: 3 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 1fd835076d..7208318d3a 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -236,6 +236,62 @@ def test_esp32_configuration_errors( FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) +@pytest.mark.parametrize( + ("config_file", "reincluded"), + [ + pytest.param( + "exclusion_reincludes.yaml", + ("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"), + id="i2c_ledc_ac_dimmer", + ), + # esp-tls has three owners; a per-owner config makes a dropped + # re-include from any single one fail the test. + pytest.param( + "exclusion_reincludes_http_request.yaml", + ("esp-tls", "esp_http_client"), + id="http_request", + ), + pytest.param( + # "mqtt" itself is deliberately not asserted: on IDF >= 6.0 it + # is a managed component and never leaves the exclusion set. + "exclusion_reincludes_mqtt.yaml", + ("esp-tls",), + id="mqtt", + ), + pytest.param( + "exclusion_reincludes_web_server.yaml", + ("esp-tls",), + id="web_server_idf", + ), + pytest.param( + "exclusion_reincludes_nextion.yaml", + ("esp-tls", "esp_http_client"), + id="nextion", + ), + ], +) +def test_default_exclusions_reincluded_by_owning_components( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + reincluded: tuple[str, ...], +) -> None: + """Components whose IDF driver is excluded by default must re-include it + during codegen; a dropped include_builtin_idf_component() call would only + surface as a missing-header failure in a full compile job.""" + from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS + + generate_main(component_config_path(config_file)) + excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + + for name in reincluded: + assert name not in excluded, f"{name} should have been re-included" + + # Components no part of this config touches stay excluded. + assert "unity" in excluded + assert "fatfs" in excluded + + def test_execute_from_psram_s3_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From a3ea77c2f1206939c0f59aa90870485270998b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 11:32:15 -0500 Subject: [PATCH 306/597] [core] Keep templated !include filenames as strings so Windows path normalization cannot corrupt them (#18549) --- esphome/components/substitutions/__init__.py | 5 +- esphome/yaml_util.py | 28 ++++++---- tests/unit_tests/test_bundle.py | 56 +++++++++++++++++++- tests/unit_tests/test_substitutions.py | 19 +++++++ tests/unit_tests/test_yaml_util.py | 27 +++++++++- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index b4fcf36c9e..5ef7a699eb 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -363,13 +363,12 @@ def resolve_include( an explicit non-goal here. """ original = include.file - original_str = str(original) filename = str( _expand_substitutions( - original_str, path + ["file"], context_vars, strict_undefined, errors + original, path + ["file"], context_vars, strict_undefined, errors ) ) - substituted = filename != original_str + substituted = filename != original if substituted: include = include.with_file(filename) try: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index d3c6caf60b..c280e550c9 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -231,18 +231,22 @@ class IncludeFile: def __init__( self, parent_file: Path, - file: Path | str, + file: str, vars: dict[str, Any] | None, yaml_loader: Callable[[Path], Any], ) -> None: self.parent_file = parent_file - self.file = Path(file) + # The raw include text may be a substitution/Jinja expression, so it + # must never round-trip through Path(): on Windows, WindowsPath str() + # rewrites "/" to "\", which Jinja then decodes as escapes like + # "\b" -> backspace (issue #18545). + self.file = file self.vars = vars self.yaml_loader = yaml_loader self._content: Any = _UNSET def __repr__(self) -> str: - return f"IncludeFile({self.file.as_posix()})" + return f"IncludeFile({self.file})" def load(self) -> Any: """Load and cache the included file content. @@ -258,15 +262,15 @@ class IncludeFile: raise Invalid( f"Cannot load include with unresolved substitutions: {self.file}" ) - self._content = self.yaml_loader(Path(self.parent_file.parent / self.file)) + self._content = self.yaml_loader(self.parent_file.parent / self.file) self._content = add_context(self._content, self.vars) return self._content def has_unresolved_expressions(self) -> bool: """Check if the filename contains substitution variables or Jinja expressions.""" - return has_substitution_or_expression(str(self.file)) + return has_substitution_or_expression(self.file) - def with_file(self, file: Path | str) -> IncludeFile: + def with_file(self, file: str) -> IncludeFile: """Clone this include with *file* as the filename.""" return IncludeFile(self.parent_file, file, self.vars, self.yaml_loader) @@ -313,7 +317,7 @@ def _candidate_include_paths(include: IncludeFile) -> list[Path]: parent_dir = include.parent_file.parent parent_resolved = include.parent_file.resolve() candidates: list[Path] = [] - for pattern in include_candidate_patterns(str(include.file)): + for pattern in include_candidate_patterns(include.file): if "*" in pattern: matches = sorted(_glob_include_candidates(parent_dir, pattern)) else: @@ -362,7 +366,7 @@ def _load_include_candidates( continue expanded_paths.add(candidate) try: - loaded = include.with_file(candidate).load() + loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: # Unlike an unresolved pattern (expected during the discovery # re-parse), a matched on-disk candidate that fails to load is a @@ -794,6 +798,10 @@ class ESPHomeLoaderMixin: file = fields.get("file") if file is None: raise yaml.MarkedYAMLError("Must include 'file'", node.start_mark) + if not isinstance(file, str): + raise yaml.MarkedYAMLError( + "Include 'file' must be a string", node.start_mark + ) vars = fields.get(CONF_VARS) return file, vars @@ -1333,11 +1341,11 @@ class ESPHomeDumper(yaml.SafeDumper): def represent_include_file(self, value): if value.vars: - mapping = {"file": value.file.as_posix(), "vars": value.vars} + mapping = {"file": value.file, "vars": value.vars} return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) - return self.represent_scalar(tag="!include", value=value.file.as_posix()) + return self.represent_scalar(tag="!include", value=value.file) def represent_id(self, value): if is_secret(value.id): diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 29e917fe44..1abc7a3ab8 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -29,8 +29,9 @@ from esphome.bundle import ( read_bundle_manifest, remap_bundle_path, ) +from esphome.components.substitutions import do_substitution_pass from esphome.core import CORE, EsphomeError -from esphome.yaml_util import force_load_include_files +from esphome.yaml_util import force_load_include_files, load_yaml # --------------------------------------------------------------------------- # Helpers @@ -1277,6 +1278,59 @@ def test_discover_files_bundles_all_include_candidates(tmp_path: Path) -> None: assert "includes/empty.yaml" in paths +@pytest.mark.parametrize("enable_proxy", [True, False]) +def test_bundle_roundtrip_templated_include_with_path_separator( + tmp_path: Path, enable_proxy: bool +) -> None: + r"""The issue-18545 flow: a Jinja !include whose branches contain "/" still + resolves after the bundle is extracted on the build server. + + Windows is the leg that regresses: the raw expression text must survive + verbatim, or its separators get rewritten to "\" and Jinja decodes + sequences like "\b" as string escapes. + """ + config_dir = _setup_config_dir( + tmp_path, + files={ + "includes/boards/board.yaml": ( + "packages:\n" + ' - !include ${ "bluetooth/bluetooth_proxy_single_core.yaml"' + ' if enable_bluetooth_proxy else "../empty.yaml" }\n' + ), + "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml": ( + "bluetooth_proxy:\n active: true\n" + ), + "includes/empty.yaml": "{}\n", + }, + ) + (config_dir / "test.yaml").write_text( + "substitutions:\n" + f" enable_bluetooth_proxy: {str(enable_proxy).lower()}\n" + "esphome:\n name: test\n" + "packages:\n - !include includes/boards/board.yaml\n" + ) + + result = ConfigBundleCreator({}).create_bundle() + bundle_path = tmp_path / "device.esphomebundle.tar.gz" + bundle_path.write_bytes(result.data) + + # Both conditional branches must ship in the bundle. + paths = [f.path for f in result.files] + assert "includes/boards/bluetooth/bluetooth_proxy_single_core.yaml" in paths + assert "includes/empty.yaml" in paths + + # Extract to a fresh directory and resolve the config from there, as a + # remote build server would. + extracted_config = extract_bundle(bundle_path, tmp_path / "remote") + config = do_substitution_pass(load_yaml(extracted_config)) + + board_pkg = config["packages"][0]["packages"][0] + if enable_proxy: + assert board_pkg == {"bluetooth_proxy": {"active": True}} + else: + assert board_pkg == {} + + def test_discover_files_candidate_outside_config_dir_skipped( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index f4063237b1..73c6e496a9 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -744,6 +744,25 @@ def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: substitutions.do_substitution_pass(config) +def test_include_filename_jinja_expression_with_path_separator( + tmp_path: Path, +) -> None: + """A jinja !include whose string literals contain "/" resolves correctly (issue #18545).""" + main_file = tmp_path / "main.yaml" + main_file.write_text( + "substitutions:\n" + " enable_bluetooth_proxy: true\n" + "result: !include " + '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }\n' + ) + (tmp_path / "bluetooth").mkdir() + (tmp_path / "bluetooth" / "proxy.yaml").write_text("value: 42\n") + + config = yaml_util.load_yaml(main_file) + config = substitutions.do_substitution_pass(config) + assert config["result"] == {"value": 42} + + def test_raise_first_undefined_logs_extras_at_debug( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e0a81652e3..3bdbd04396 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -701,6 +701,31 @@ def test_include_file_has_unresolved_expressions( assert include.has_unresolved_expressions() == expected +def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None: + """The mapping !include form rejects a non-string 'file' with a clear error.""" + entry = tmp_path / "entry.yaml" + entry.write_text("wifi: !include\n file: [not, a, string]\n") + with pytest.raises(EsphomeError, match="Include 'file' must be a string"): + yaml_util.load_yaml(entry) + + +def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> None: + """A templated filename keeps its verbatim text (issue #18545).""" + parent = tmp_path / "main.yaml" + expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }' + include = yaml_util.IncludeFile(parent, expr, None, lambda _: {}) + assert include.file == expr + assert include.has_unresolved_expressions() + assert repr(include) == f"IncludeFile({expr})" + + +def test_represent_include_file_templated() -> None: + """Dumping a templated IncludeFile emits the raw expression unchanged.""" + expr = '${ "a/b.yaml" if flag else "../c.yaml" }' + include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {}) + assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n" + + def test_include_in_list_context() -> None: """!include of a file returning a list is handled correctly, including when that list itself contains a nested IncludeFile.""" @@ -1051,7 +1076,7 @@ class _StubInclude: ) -> None: # Default parent lives in a nonexistent directory so unresolved # stubs never glob real files during candidate expansion. - self.file = Path(file) + self.file = file self.parent_file = parent_file or Path("/nonexistent/parent.yaml") self._unresolved = unresolved self._load_result = load_result if load_result is not None else {} From 2ab09e1a77227718e1d9318319e8745bcfab01ac Mon Sep 17 00:00:00 2001 From: David van 't Wout Date: Thu, 20 Aug 2026 19:30:33 +0200 Subject: [PATCH 307/597] [core] Add add_cmake_arg (#18498) --- esphome/build_gen/espidf.py | 38 +++++++++------- esphome/build_gen/platformio.py | 11 +++++ esphome/codegen.py | 1 + esphome/components/esp32/__init__.py | 19 ++++---- esphome/core/__init__.py | 35 ++++++++++++++- esphome/cpp_generator.py | 5 +++ tests/unit_tests/build_gen/test_espidf.py | 27 ++++++++++++ tests/unit_tests/build_gen/test_platformio.py | 44 +++++++++++++++++++ tests/unit_tests/test_core.py | 32 ++++++++++++++ 9 files changed, 187 insertions(+), 25 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index b65ce23307..5d4e6b8401 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -72,6 +72,13 @@ def has_discovered_components() -> bool: return get_available_components() is not None +def _cmake_quote(value: str) -> str: + """Quote a cmake arg value for a set() line. add_cmake_arg rejects + whitespace, quotes, and '$', so only backslashes need escaping.""" + escaped = value.replace("\\", "\\\\") + return f'"{escaped}"' + + def get_project_cmakelists(minimal: bool = False) -> str: """Generate the top-level CMakeLists.txt for ESP-IDF project. @@ -114,6 +121,15 @@ def get_project_cmakelists(minimal: bool = False) -> str: else "" ) + # CMake variables registered via cg.add_cmake_arg(). Emitted before + # include(project.cmake) so values like EXCLUDE_COMPONENTS are already + # set when project.cmake seeds the component list, and on minimal + # (discovery) writes too so excluded components never register. + cmake_args = "\n".join( + f"set({name} {_cmake_quote(value)})" + for name, value in sorted(CORE.cmake_args.items()) + ) + # Per-project list exposed as a CMake variable so converted PIO libs # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # project-specific names into their cached CMakeLists. @@ -129,18 +145,6 @@ def get_project_cmakelists(minimal: bool = False) -> str: for name in get_managed_component_require_names() ) - # Components excluded from the build (DEFAULT_EXCLUDED_IDF_COMPONENTS - # minus per-component re-includes). project.cmake reads the plain - # EXCLUDE_COMPONENTS variable when seeding the component list, so this - # must be set before project(). Emitted on minimal writes too so the - # discovery reconfigure never registers the excluded components. - excluded_components = get_excluded_builtin_components() - exclude_components_var = ( - f'set(EXCLUDE_COMPONENTS "{";".join(excluded_components)}")' - if excluded_components - else "" - ) - # Built-in IDF components exposed via our own property (not IDF's # __COMPONENT_REQUIRES_COMMON, which would append them to every # component's REQUIRES including real IDF components). Referenced by @@ -150,13 +154,17 @@ def get_project_cmakelists(minimal: bool = False) -> str: # project_description.json from a build without exclusions may still # list them, and requiring an excluded component pulls it back into # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the + # two can never disagree within one generated file. builtin_components_property = ( "" if minimal else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" for name in sorted( - set(get_available_components() or []).difference(excluded_components) + set(get_available_components() or []).difference( + CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";") + ) ) ) ) @@ -184,9 +192,9 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1) set(IDF_TARGET {idf_target}) set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) -include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cmake_args} -{exclude_components_var} +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index b63c4b733d..0a12d344a0 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -63,6 +63,17 @@ def get_ini_content(): # Add extra script for C++ flags CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) + # Add CMake args. A user-supplied value (str or list) is deliberately + # replaced; this option was always overwritten at FINAL priority. + if CORE.cmake_args: + CORE.add_platformio_option( + "board_build.cmake_extra_args", + " ".join( + f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items()) + ), + replace=True, + ) + content = "[platformio]\n" content += f"description = ESPHome {__version__}\n" diff --git a/esphome/codegen.py b/esphome/codegen.py index 2430f17f3a..2aa6a70abd 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cmake_arg, add_cxx_build_flag, add_define, add_global, diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f1f039922a..d6ed6d9399 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -760,9 +760,10 @@ def include_builtin_idf_component(name: str) -> None: def get_excluded_builtin_components() -> list[str]: """Return the sorted built-in IDF components excluded from the build. - Single accessor for both build writers: the PlatformIO path passes it as - ``-DEXCLUDE_COMPONENTS`` and the native ESP-IDF path emits it into the - generated CMakeLists. + The set reaches both build writers as the ``EXCLUDE_COMPONENTS`` CMake + arg (registered via ``cg.add_cmake_arg`` at FINAL priority); the native + ESP-IDF writer also reads it directly to filter the built-in component + list. """ return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) @@ -2148,14 +2149,16 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +def register_exclude_components_cmake_arg() -> None: + """Register the current exclusion set as the EXCLUDE_COMPONENTS cmake arg.""" + if excluded := get_excluded_builtin_components(): + cg.add_cmake_arg("EXCLUDE_COMPONENTS", ";".join(excluded)) + + @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if excluded := get_excluded_builtin_components(): - cg.add_platformio_option( - "board_build.cmake_extra_args", - f"-DEXCLUDE_COMPONENTS={';'.join(excluded)}", - ) + register_exclude_components_cmake_arg() @coroutine_with_priority(CoroPriority.FINAL) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 534b740a5d..0f1ac9213e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -641,6 +641,8 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A map of CMake args to apply to build systems that use CMake. + self.cmake_args: dict[str, str] = {} # A set of build flags that apply to C++ compiles only (CXXFLAGS / # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C self.cxx_build_flags: set[str] = set() @@ -704,6 +706,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cmake_args = {} self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None @@ -1062,6 +1065,30 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cmake_arg(self, name: str, value: str) -> None: + """Register a CMake variable for CMake-based toolchains. + + The value must not contain whitespace or quotes (the PlatformIO + backend passes all args to CMake as a single space-joined string + of ``-DNAME=VALUE`` pairs) or ``$`` (expanded by CMake on the + ESP-IDF path but interpolated differently or passed through by + PlatformIO). + """ + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise ValueError(f"Invalid CMake arg name: {name!r}") + if re.search(r"[\s\"'$]", value): + raise ValueError( + f"CMake arg {name} value {value!r} must not contain " + "whitespace, quotes, or '$'" + ) + old = self.cmake_args.get(name) + if old is not None and old != value: + _LOGGER.warning( + "CMake arg %s already set to %s; overwriting with %s", name, old, value + ) + self.cmake_args[name] = value + _LOGGER.debug("Adding CMake arg: %s=%s", name, value) + def add_cxx_build_flag(self, build_flag: str) -> str: self.cxx_build_flags.add(build_flag) _LOGGER.debug("Adding C++ build flag: %s", build_flag) @@ -1091,10 +1118,14 @@ class EsphomeCore: _LOGGER.debug("Adding define: %s", define) return define - def add_platformio_option(self, key: str, value: str | list[str]) -> None: + def add_platformio_option( + self, key: str, value: str | list[str], *, replace: bool = False + ) -> None: + """Set a platformio.ini option; list values append to an existing list + unless ``replace`` is True, which overwrites any existing value.""" new_val = value old_val = self.platformio_options.get(key) - if isinstance(old_val, list): + if not replace and isinstance(old_val, list): assert isinstance(value, list) new_val = old_val + value self.platformio_options[key] = new_val diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6bcf4eed77..e6b8c0de42 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,11 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cmake_arg(name: str, value: str) -> None: + """Add a CMake arg for CMake-based toolchains; see ``EsphomeCore.add_cmake_arg``.""" + CORE.add_cmake_arg(name, value) + + def add_cxx_build_flag(build_flag: str) -> None: """Add a global build flag that applies to C++ compiles only. diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index ec01000920..29010bcf0e 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( KEY_PATH, KEY_REF, KEY_REPO, + register_exclude_components_cmake_arg, ) import esphome.config_validation as cv from esphome.const import KEY_CORE @@ -137,6 +138,27 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_project_cmakelists_emits_cmake_args() -> None: + """Args registered via CORE.add_cmake_arg() are emitted as set() lines, + on minimal writes too.""" + CORE.add_cmake_arg("EXECUTABLE_COMPONENT_NAME", "src") + + content = _render(minimal=True) + + assert 'set(EXECUTABLE_COMPONENT_NAME "src")' in content + + +def test_get_project_cmakelists_escapes_backslashes_in_cmake_args() -> None: + """Backslashes (the only character escaping applies to; the rest are + rejected at registration) are doubled so CMake reads the value back + verbatim.""" + CORE.add_cmake_arg("MY_PATH", r"C:\esp\idf") + + content = _render(minimal=True) + + assert r'set(MY_PATH "C:\\esp\\idf")' in content + + def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None: """Excluded components are passed to IDF via EXCLUDE_COMPONENTS and are dropped from ESPHOME_PROJECT_BUILTIN_COMPONENTS even when a stale @@ -151,6 +173,7 @@ def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None }, ) CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "esp_lcd"} + register_exclude_components_cmake_arg() content = _render() @@ -169,6 +192,7 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: """The discovery (minimal) write also excludes components so they never register in project_description.json.""" CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + register_exclude_components_cmake_arg() content = _render(minimal=True) @@ -177,6 +201,8 @@ def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" + register_exclude_components_cmake_arg() + content = _render() assert "EXCLUDE_COMPONENTS" not in content @@ -197,6 +223,7 @@ def test_include_builtin_idf_component_removes_exclusion() -> None: assert get_excluded_builtin_components() == ["unity"] + register_exclude_components_cmake_arg() content = _render() assert 'set(EXCLUDE_COMPONENTS "unity")' in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 3df2fb1036..20acbe302c 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -169,6 +169,7 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(CORE, "platformio_libraries", {}) monkeypatch.setattr(CORE, "build_flags", set()) monkeypatch.setattr(CORE, "build_unflags", set()) + monkeypatch.setattr(CORE, "cmake_args", {}) def test_get_ini_content_pins_cpp_standard( @@ -202,6 +203,49 @@ def test_get_ini_content_no_cpp_standard( assert "-std=" not in content +def test_get_ini_content_emits_cmake_args( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Registered args are space-joined into one option, sorted by name.""" + monkeypatch.setattr( + CORE, + "cmake_args", + {"EXECUTABLE_COMPONENT_NAME": "src", "EXCLUDE_COMPONENTS": "unity"}, + ) + + content = platformio.get_ini_content() + + assert ( + "board_build.cmake_extra_args = " + "-DEXCLUDE_COMPONENTS=unity -DEXECUTABLE_COMPONENT_NAME=src" in content + ) + + +def test_get_ini_content_no_cmake_option_when_no_args(clean_core: None) -> None: + """No board_build.cmake_extra_args line at all when nothing registered + (ESP8266/RP2040/LibreTiny builds must not get a blank option).""" + content = platformio.get_ini_content() + + assert "board_build.cmake_extra_args" not in content + + +def test_get_ini_content_overwrites_list_valued_user_cmake_option( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """A user-supplied board_build.cmake_extra_args may be a list; the + registered args must replace it without tripping add_platformio_option's + list-append assert.""" + monkeypatch.setattr( + CORE, "platformio_options", {"board_build.cmake_extra_args": ["-DFOO=1"]} + ) + monkeypatch.setattr(CORE, "cmake_args", {"EXECUTABLE_COMPONENT_NAME": "src"}) + + content = platformio.get_ini_content() + + assert "board_build.cmake_extra_args = -DEXECUTABLE_COMPONENT_NAME=src" in content + assert "-DFOO=1" not in content + + def test_write_cxx_flags_script_emits_registered_flags( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 7f00d00ef7..c373116106 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -990,3 +990,35 @@ class TestEsphomeCore: ) # The unflag is still recorded either way. assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} + + def test_add_cmake_arg(self, target) -> None: + target.add_cmake_arg("EXCLUDE_COMPONENTS", "unity;esp_lcd") + assert target.cmake_args == {"EXCLUDE_COMPONENTS": "unity;esp_lcd"} + + @pytest.mark.parametrize("name", ["", "BAD NAME", 'A"B', "A(B)", "1ABC"]) + def test_add_cmake_arg__rejects_invalid_name(self, target, name: str) -> None: + with pytest.raises(ValueError, match="Invalid CMake arg name"): + target.add_cmake_arg(name, "value") + + @pytest.mark.parametrize("value", ["a b", "a\tb", 'a"b', "a'b", "a${FOO}b"]) + def test_add_cmake_arg__rejects_invalid_value(self, target, value: str) -> None: + """Whitespace and quotes are rejected (the PlatformIO backend passes + args as one space-joined string, which would split such a value), and + so is '$' (expanded differently by CMake and PlatformIO).""" + with pytest.raises(ValueError, match="must not contain"): + target.add_cmake_arg("MY_ARG", value) + + def test_add_cmake_arg__warns_on_overwrite( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Re-registering with a different value is last-writer-wins; warn so + the silently dropped value is diagnosable.""" + target.add_cmake_arg("MY_ARG", "one") + target.add_cmake_arg("MY_ARG", "one") + assert "overwriting" not in caplog.text + + target.add_cmake_arg("MY_ARG", "two") + assert ( + "CMake arg MY_ARG already set to one; overwriting with two" in caplog.text + ) + assert target.cmake_args == {"MY_ARG": "two"} From 6343c11873fb62b1ed83b841f4e9c46e5fc73697 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:56:02 +1200 Subject: [PATCH 308/597] [core] Add type annotations to component Python (5/11) (#18342) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/bedjet/__init__.py | 6 ++++-- esphome/components/bedjet/climate/__init__.py | 3 ++- esphome/components/bedjet/fan/__init__.py | 3 ++- esphome/components/bedjet/sensor/__init__.py | 3 ++- esphome/components/bme680_bsec/__init__.py | 3 ++- esphome/components/bme680_bsec/sensor.py | 6 ++++-- esphome/components/bme680_bsec/text_sensor.py | 6 ++++-- esphome/components/cs5460a/sensor.py | 14 +++++++++++--- esphome/components/esp32_touch/__init__.py | 13 ++++++++----- .../components/esp32_touch/binary_sensor.py | 3 ++- esphome/components/esp8266_pwm/output.py | 14 +++++++++++--- esphome/components/factory_reset/__init__.py | 7 ++++--- .../factory_reset/button/__init__.py | 3 ++- .../factory_reset/switch/__init__.py | 3 ++- esphome/components/hbridge/fan/__init__.py | 12 ++++++++++-- esphome/components/hbridge/light/__init__.py | 3 ++- esphome/components/hbridge/switch/__init__.py | 3 ++- esphome/components/hmc5883l/sensor.py | 15 +++++++++++---- esphome/components/mhz19/sensor.py | 19 ++++++++++++++++--- esphome/components/mpr121/__init__.py | 14 +++++++++----- .../mpr121/binary_sensor/__init__.py | 3 ++- esphome/components/pcf85063/time.py | 19 ++++++++++++++++--- esphome/components/pcf8563/time.py | 19 ++++++++++++++++--- esphome/components/pcm5122/audio_dac.py | 12 +++++++----- esphome/components/pcm5122/switch/__init__.py | 3 ++- esphome/components/pmwcs3/sensor.py | 19 ++++++++++++++++--- esphome/components/qmc5883l/sensor.py | 13 +++++++++---- .../components/remote_transmitter/__init__.py | 15 +++++++++++---- esphome/components/rotary_encoder/sensor.py | 14 +++++++++++--- .../components/rp2040_pio_led_strip/light.py | 8 ++++---- esphome/components/rx8130/time.py | 19 ++++++++++++++++--- esphome/components/servo/__init__.py | 19 ++++++++++++++++--- esphome/components/sx1509/__init__.py | 10 ++++++---- .../sx1509/binary_sensor/__init__.py | 3 ++- esphome/components/sx1509/output/__init__.py | 3 ++- 35 files changed, 246 insertions(+), 86 deletions(-) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index d4bf813846..1b967e665a 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jhansche"] DEPENDENCIES = ["ble_client"] @@ -32,12 +34,12 @@ BEDJET_CLIENT_SCHEMA = cv.Schema( ) -async def register_bedjet_child(var, config): +async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_BEDJET_ID]) cg.add(parent.register_child(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 4de9dcca0b..36650d643c 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate import esphome.config_validation as cv from esphome.const import CONF_HEAT_MODE, CONF_TEMPERATURE_SOURCE +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bedjet/fan/__init__.py b/esphome/components/bedjet/fan/__init__.py index a4a611fefc..f5dfe32f4c 100644 --- a/esphome/components/bedjet/fan/__init__.py +++ b/esphome/components/bedjet/fan/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -16,7 +17,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bedjet/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index fa9ca7953e..595e798e49 100644 --- a/esphome/components/bedjet/sensor/__init__.py +++ b/esphome/components/bedjet/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(BEDJET_CLIENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_bedjet_child(var, config) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index e1e01facd0..35df2a7ea3 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -3,6 +3,7 @@ from esphome.components import esp32, i2c from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework +from esphome.types import ConfigType CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] @@ -76,7 +77,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index bdc8d8f2d3..153890b57f 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -29,6 +29,8 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent @@ -110,7 +112,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -120,7 +122,7 @@ async def setup_conf(config, key, hub): ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme680_bsec/text_sensor.py b/esphome/components/bme680_bsec/text_sensor.py index 1fbb9e2aeb..6da1c9d287 100644 --- a/esphome/components/bme680_bsec/text_sensor.py +++ b/esphome/components/bme680_bsec/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, BME680BSECComponent @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 0c6ae0d821..5f14457101 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@balrog-kun"] DEPENDENCIES = ["spi"] @@ -40,7 +43,7 @@ CONF_VOLTAGE_HPF = "voltage_hpf" CONF_PULSE_ENERGY = "pulse_energy" -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: current_gain = abs(config[CONF_CURRENT_GAIN]) * ( 1.0 if config[CONF_PGA_GAIN] == "10X" else 5.0 ) @@ -105,7 +108,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -138,6 +141,11 @@ async def to_code(config): ), synchronous=True, ) -async def restart_action_to_code(config, action_id, template_arg, args): +async def restart_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 10ad339b12..ede6beb9b6 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable, Iterable import logging +from typing import Any import esphome.codegen as cg from esphome.components import esp32 @@ -23,6 +25,7 @@ from esphome.const import ( CONF_VOLTAGE_ATTENUATION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -181,7 +184,7 @@ EFFECTIVE_HIGH_VOLTAGE = { } -def validate_touch_pad(value): +def validate_touch_pad(value: Any) -> int: value = gpio.gpio_pin_number_validator(value) variant = get_esp32_variant() pads = TOUCH_PADS.get(variant) @@ -192,7 +195,7 @@ def validate_touch_pad(value): return pads[value] # Return integer channel ID -def validate_variant_vars(config): +def validate_variant_vars(config: ConfigType) -> ConfigType: variant = get_esp32_variant() invalid_vars = set() if variant == VARIANT_ESP32: @@ -219,8 +222,8 @@ def validate_variant_vars(config): return config -def validate_voltage(values): - def validator(value): +def validate_voltage(values: Iterable[str]) -> Callable[[Any], str]: + def validator(value: Any) -> str: if isinstance(value, float) and value.is_integer(): value = int(value) value = cv.string(value) @@ -300,7 +303,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # New unified touch sensor driver include_builtin_idf_component("esp_driver_touch_sens") diff --git a/esphome/components/esp32_touch/binary_sensor.py b/esphome/components/esp32_touch/binary_sensor.py index 75560d71b1..2489c2abc1 100644 --- a/esphome/components/esp32_touch/binary_sensor.py +++ b/esphome/components/esp32_touch/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType from . import ESP32TouchComponent, esp32_touch_ns, validate_touch_pad @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(ESP32TouchBinarySensor).exten ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_ESP32_TOUCH_ID]) var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index f119a6ba9f..dd151a3e04 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,11 +4,14 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] -def valid_pwm_pin(value): +def valid_pwm_pin(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] cv.one_of(0, 1, 2, 3, 4, 5, 9, 10, 12, 13, 14, 15, 16)(num) return value @@ -35,7 +38,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: require_waveform() var = cg.new_Pvariable(config[CONF_ID]) @@ -59,7 +62,12 @@ async def to_code(config) -> None: ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index d5d5d2ecb5..a9064eb18f 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@anatoly-savchenkov"] @@ -23,7 +24,7 @@ CONF_RESETS_REQUIRED = "resets_required" CONF_ON_INCREMENT = "on_increment" -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_RESETS_REQUIRED in config: return cv.only_on( [ @@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): @@ -81,7 +82,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if reset_count := config.get(CONF_RESETS_REQUIRED): var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/factory_reset/button/__init__.py b/esphome/components/factory_reset/button/__init__.py index 61df5f297b..040614c151 100644 --- a/esphome/components/factory_reset/button/__init__.py +++ b/esphome/components/factory_reset/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import factory_reset_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = button.button_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await button.register_button(var, config) diff --git a/esphome/components/factory_reset/switch/__init__.py b/esphome/components/factory_reset/switch/__init__.py index a384a57f80..69a635a917 100644 --- a/esphome/components/factory_reset/switch/__init__.py +++ b/esphome/components/factory_reset/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import factory_reset_ns @@ -17,6 +18,6 @@ CONFIG_SCHEMA = switch.switch_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 8ea8677ba2..2cf1693b47 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_PRESET_MODES, CONF_SPEED_COUNT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import hbridge_ns @@ -54,12 +57,17 @@ CONFIG_SCHEMA = ( maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), synchronous=True, ) -async def fan_hbridge_brake_to_code(config, action_id, template_arg, args): +async def fan_hbridge_brake_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan( config, config[CONF_SPEED_COUNT], diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f9451e2594..f7866cb990 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType from .. import hbridge_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index e26bd6b1d8..294be6ed5f 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_OPTIMISTIC, CONF_PULSE_LENGTH, CONF_WAIT_TIME +from esphome.types import ConfigType from .. import hbridge_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index cf3c594f36..a2e1f8054a 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv @@ -17,6 +20,8 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -59,14 +64,16 @@ HMC5883L_RANGES = { } -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -112,7 +119,7 @@ CONFIG_SCHEMA = ( ) -def auto_data_rate(config): +def auto_data_rate(config: ConfigType) -> MockObj: interval_msec = config[CONF_UPDATE_INTERVAL].total_milliseconds interval_hz = 1000.0 / interval_msec for datarate in sorted(HMC5883LDatarates.keys()): @@ -121,7 +128,7 @@ def auto_data_rate(config): return HMC5883LDatarates[75] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index b7d0ad1998..33cb27080c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -78,7 +81,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -129,7 +132,12 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_no_args_action_to_code(config, action_id, template_arg, args): +async def mhz19_no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -151,7 +159,12 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( RANGE_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_detection_range_set_to_code(config, action_id, template_arg, args): +async def mhz19_detection_range_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) detection_range = config.get(CONF_DETECTION_RANGE) diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index 0bf9377275..da56b4ff4b 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -12,7 +12,9 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType CONF_TOUCH_THRESHOLD = "touch_threshold" CONF_RELEASE_THRESHOLD = "release_threshold" @@ -49,7 +51,7 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: fconf = fv.full_config.get() max_touch_channel = 3 if (binary_sensors := fconf.get(CONF_BINARY_SENSOR)) is not None: @@ -71,7 +73,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_touch_debounce(config[CONF_TOUCH_DEBOUNCE])) cg.add(var.set_release_debounce(config[CONF_RELEASE_DEBOUNCE])) @@ -82,7 +84,7 @@ async def to_code(config): await i2c.register_i2c_device(var, config) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if bool(value[CONF_INPUT]) == bool(value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") return value @@ -105,7 +107,9 @@ MPR121_GPIO_PIN_SCHEMA = pins.gpio_base_schema( ) -def mpr121_pin_final_validate(pin_config, parent_config): +def mpr121_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: if pin_config[CONF_NUMBER] <= parent_config[CONF_MAX_TOUCH_CHANNEL]: raise cv.Invalid( "Pin number must be higher than the max touch channel of the MPR121 component", @@ -115,7 +119,7 @@ def mpr121_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_MPR121, MPR121_GPIO_PIN_SCHEMA, mpr121_pin_final_validate ) -async def mpr121_gpio_pin_to_code(config): +async def mpr121_gpio_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MPR121]) diff --git a/esphome/components/mpr121/binary_sensor/__init__.py b/esphome/components/mpr121/binary_sensor/__init__.py index 1252a65a84..565789cdc3 100644 --- a/esphome/components/mpr121/binary_sensor/__init__.py +++ b/esphome/components/mpr121/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_CHANNEL +from esphome.types import ConfigType from .. import ( CONF_MPR121_ID, @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(MPR121BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_MPR121_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 8e19178cc9..771461905e 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@brogon"] DEPENDENCIES = ["i2c"] @@ -31,7 +34,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf85063_write_time_to_code(config, action_id, template_arg, args): +async def pcf85063_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -47,13 +55,18 @@ async def pcf85063_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def pcf85063_read_time_to_code(config, action_id, template_arg, args): +async def pcf85063_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 1502158c29..8a0b871be9 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -34,7 +37,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def pcf8563_write_time_to_code(config, action_id, template_arg, args): +async def pcf8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -50,13 +58,18 @@ async def pcf8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def pcf8563_read_time_to_code(config, action_id, template_arg, args): +async def pcf8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index c18fb3993e..5091efabea 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -13,6 +13,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] @@ -50,7 +52,7 @@ PCM5122_CHANNEL_MIX_ENUM = { _validate_bits = cv.float_with_unit("bits", "bit") -def _validate_volume_range(config): +def _validate_volume_range(config: ConfigType) -> ConfigType: if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") return config @@ -90,7 +92,7 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_pin_mode(value): +def _validate_pin_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -98,7 +100,7 @@ def _validate_pin_mode(value): return value -def _validate_pin(value): +def _validate_pin(value: ConfigType) -> ConfigType: if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") return value @@ -120,7 +122,7 @@ PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) -async def pcm5122_pin_to_code(config): +async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_PCM5122]) @@ -130,7 +132,7 @@ async def pcm5122_pin_to_code(config): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py index 10519da895..829adeccb7 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_parented(var, config[CONF_PCM5122]) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index c0bc54c5ba..ae22b3e0d6 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -10,6 +10,9 @@ from esphome.const import ( ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@SeByDocKy"] DEPENDENCIES = ["i2c"] @@ -72,7 +75,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -114,7 +117,12 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): +async def pmwcs3_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, parent) @@ -134,7 +142,12 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( PMWCS3_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): +async def pmwcs3newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) address = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index fe34381ad8..e0186be163 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -60,7 +63,7 @@ QMC5883LOversamplings = { } -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if ( config[CONF_UPDATE_INTERVAL].total_milliseconds < 15 and CONF_DRDY_PIN not in config @@ -72,14 +75,16 @@ def validate_config(config): return config -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -137,7 +142,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 521c3daf87..a97b925e06 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -18,7 +18,9 @@ from esphome.const import ( CONF_VALUE, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -94,7 +96,7 @@ CONFIG_SCHEMA = ( ) -def _validate_non_blocking(config): +def _validate_non_blocking(config: ConfigType) -> None: if ( CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT @@ -125,7 +127,12 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( DIGITAL_WRITE_ACTION_SCHEMA, synchronous=True, ) -async def digital_write_action_to_code(config, action_id, template_arg, args): +async def digital_write_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.bool_) @@ -133,7 +140,7 @@ async def digital_write_action_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 0e5a03523d..72722ec4b1 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") @@ -44,7 +47,7 @@ RotaryEncoderSetValueAction = rotary_encoder_ns.class_( ) -def validate_min_max_value(config): +def validate_min_max_value(config: ConfigType) -> ConfigType: if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: min_val = config[CONF_MIN_VALUE] max_val = config[CONF_MAX_VALUE] @@ -92,7 +95,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -126,7 +129,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): +async def sensor_template_publish_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 9f7479edd0..5b7259f9e5 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -18,7 +18,7 @@ from esphome.types import ConfigType from esphome.util import _LOGGER -def get_nops(timing): +def get_nops(timing: float) -> list[float | str]: """ Calculate the number of NOP instructions required to wait for a given amount of time. """ @@ -39,7 +39,7 @@ def get_nops(timing): return nops -def generate_assembly_code(id, t0h, t0l, t1h, t1l): +def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: """ Generate assembly code with the given timing values. """ @@ -125,7 +125,7 @@ writezero: return assembly_template + const_csdk_code -def time_to_cycles(time_us): +def time_to_cycles(time_us: float) -> int: cycles_per_us = 57.5 return round(float(time_us) * cycles_per_us) @@ -172,7 +172,7 @@ CONF_BIT1_HIGH = "bit1_high" CONF_BIT1_LOW = "bit1_low" -def _validate_timing(value): +def _validate_timing(value: str) -> float: # if doesn't end with us, raise error if not value.endswith("us"): raise cv.Invalid("Timing must be in microseconds (us)") diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 4f6310358c..40d10e9f6b 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def rx8130_write_time_to_code(config, action_id, template_arg, args): +async def rx8130_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def rx8130_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def rx8130_read_time_to_code(config, action_id, template_arg, args): +async def rx8130_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index c2eaefe455..666c7dbcdd 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_RESTORE, CONF_TRANSITION_LENGTH, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) @@ -39,7 +42,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -64,7 +67,12 @@ async def to_code(config): ), synchronous=True, ) -async def servo_write_to_code(config, action_id, template_arg, args): +async def servo_write_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) @@ -82,6 +90,11 @@ async def servo_write_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def servo_detach_to_code(config, action_id, template_arg, args): +async def servo_detach_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index b61b92fd1e..c1e4e11d54 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, CONF_TRIGGER_ID, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_KEYPAD = "keypad" CONF_KEYS = "keys" @@ -40,7 +42,7 @@ SX1509KeyTrigger = sx1509_ns.class_( ) -def check_keys(config): +def check_keys(config: ConfigType) -> ConfigType: if ( CONF_KEYS in config and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] @@ -82,7 +84,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -104,7 +106,7 @@ async def to_code(config): await automation.build_automation(trigger, [(cg.uint8, "x")], tconf) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: if not (value[CONF_INPUT] or value[CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") if value[CONF_INPUT] and value[CONF_OUTPUT]: @@ -142,7 +144,7 @@ SX1509_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_SX1509, SX1509_PIN_SCHEMA) -async def sx1509_pin_to_code(config): +async def sx1509_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_SX1509]) cg.add(var.set_parent(parent)) diff --git a/esphome/components/sx1509/binary_sensor/__init__.py b/esphome/components/sx1509/binary_sensor/__init__.py index 0ceca77a5d..154a841348 100644 --- a/esphome/components/sx1509/binary_sensor/__init__.py +++ b/esphome/components/sx1509/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ROW +from esphome.types import ConfigType from .. import CONF_SX1509_ID, SX1509Component, sx1509_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(SX1509BinarySensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_SX1509_ID]) cg.add(var.set_row_col(config[CONF_ROW], config[CONF_COL])) diff --git a/esphome/components/sx1509/output/__init__.py b/esphome/components/sx1509/output/__init__.py index 9e2db7bb10..aed5ab7dd4 100644 --- a/esphome/components/sx1509/output/__init__.py +++ b/esphome/components/sx1509/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_SX1509_ID, SX1509Component, sx1509_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SX1509_ID]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From c006e9804a2e88d5852ca6761800c01c0cde6fa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:56:48 +1200 Subject: [PATCH 309/597] [core] Add type annotations to component Python (9/11) (#18346) --- esphome/components/as3935/__init__.py | 4 +++- esphome/components/as3935/binary_sensor.py | 3 ++- esphome/components/as3935/sensor.py | 3 ++- esphome/components/bthome_mithermometer/__init__.py | 8 ++++++-- esphome/components/bthome_mithermometer/sensor.py | 3 ++- esphome/components/color/__init__.py | 11 +++++++---- esphome/components/ds248x/__init__.py | 7 ++++--- esphome/components/ds248x/one_wire.py | 5 +++-- esphome/components/emontx/__init__.py | 10 +++++++--- esphome/components/gdk101/__init__.py | 3 ++- esphome/components/gdk101/binary_sensor.py | 3 ++- esphome/components/gdk101/sensor.py | 3 ++- esphome/components/gdk101/text_sensor.py | 3 ++- esphome/components/hc8/sensor.py | 12 ++++++++++-- esphome/components/lcd_base/__init__.py | 10 +++++++--- esphome/components/libretiny_pwm/output.py | 12 ++++++++++-- esphome/components/lightwaverf/__init__.py | 12 ++++++++++-- esphome/components/max17043/sensor.py | 12 ++++++++++-- esphome/components/nau7802/sensor.py | 12 ++++++++++-- esphome/components/ntc/sensor.py | 12 +++++++----- esphome/components/openthread_info/sensor.py | 5 +++-- esphome/components/openthread_info/text_sensor.py | 5 +++-- esphome/components/pmsx003/sensor.py | 12 ++++++++---- esphome/components/pzemac/sensor.py | 11 +++++++++-- esphome/components/pzemdc/sensor.py | 11 +++++++++-- esphome/components/remote_receiver/__init__.py | 9 ++++++--- esphome/components/remote_receiver/binary_sensor.py | 3 ++- esphome/components/scd30/sensor.py | 12 +++++++++--- esphome/components/senseair/sensor.py | 12 ++++++++++-- esphome/components/sml/__init__.py | 6 ++++-- esphome/components/sml/sensor/__init__.py | 3 ++- esphome/components/sml/text_sensor/__init__.py | 3 ++- esphome/components/sn74hc595/__init__.py | 12 ++++++++---- esphome/components/spa06_base/__init__.py | 12 +++++++----- esphome/components/sy6970/__init__.py | 3 ++- esphome/components/sy6970/binary_sensor/__init__.py | 3 ++- esphome/components/sy6970/sensor/__init__.py | 3 ++- esphome/components/sy6970/text_sensor/__init__.py | 3 ++- esphome/components/tm1638/binary_sensor/__init__.py | 3 ++- esphome/components/tm1638/display.py | 3 ++- esphome/components/tm1638/output/__init__.py | 3 ++- esphome/components/tm1638/switch/__init__.py | 3 ++- esphome/components/uponor_smatrix/__init__.py | 6 ++++-- .../components/uponor_smatrix/climate/__init__.py | 3 ++- .../components/uponor_smatrix/sensor/__init__.py | 3 ++- esphome/components/vl53l0x/sensor.py | 10 +++++++--- esphome/components/weikai/__init__.py | 12 +++++++----- esphome/components/zephyr_ble_server/__init__.py | 13 ++++++++++--- 48 files changed, 238 insertions(+), 97 deletions(-) diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index 70015c53b9..bd02d22d1b 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -14,6 +14,8 @@ from esphome.const import ( CONF_TUNE_ANTENNA, CONF_WATCHDOG_THRESHOLD, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -42,7 +44,7 @@ AS3935_SCHEMA = cv.Schema( ) -async def setup_as3935(var, config): +async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) diff --git a/esphome/components/as3935/binary_sensor.py b/esphome/components/as3935/binary_sensor.py index 10004e69dc..929b653294 100644 --- a/esphome/components/as3935/binary_sensor.py +++ b/esphome/components/as3935/binary_sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -13,7 +14,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) var = await binary_sensor.new_binary_sensor(config) cg.add(hub.set_thunder_alert_binary_sensor(var)) diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index 9b43155563..b727b8fdb9 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_KILOMETER, ) +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -31,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) if distance_config := config.get(CONF_DISTANCE): diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 4be7ca8268..ed0cbaa9e1 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -3,6 +3,8 @@ from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] @@ -14,7 +16,9 @@ BTHomeMiThermometer = bthome_mithermometer_ns.class_( ) -def bthome_mithermometer_base_schema(extra_schema=None): +def bthome_mithermometer_base_schema( + extra_schema: cv.Schema | dict | None = None, +) -> cv.All: if extra_schema is None: extra_schema = {} return cv.All( @@ -32,7 +36,7 @@ def bthome_mithermometer_base_schema(extra_schema=None): ) -async def setup_bthome_mithermometer(var, config): +async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 02551391ad..f559d0aa9b 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -20,6 +20,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer @@ -67,7 +68,7 @@ CONFIG_SCHEMA = bthome_mithermometer_base_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index c39c5924af..70240eff07 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -1,5 +1,8 @@ +from typing import Any + from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_BLUE, CONF_GREEN, CONF_ID, CONF_RED, CONF_WHITE +from esphome.types import ConfigType ColorStruct = cg.esphome_ns.struct("Color") @@ -14,7 +17,7 @@ CONF_WHITE_INT = "white_int" CONF_HEX = "hex" -def hex_color(value): +def hex_color(value: Any) -> tuple[int, int, int]: if isinstance(value, int): value = str(value) if not isinstance(value, str): @@ -39,7 +42,7 @@ components = { } -def validate_color(config): +def validate_color(config: ConfigType) -> ConfigType: has_components = set(config) & components has_hex = CONF_HEX in config if has_hex and has_components: @@ -68,7 +71,7 @@ CONFIG_SCHEMA = cv.All( ) -def from_rgbw(config): +def from_rgbw(config: ConfigType) -> tuple[int, int, int, int]: r = 0 if CONF_RED in config: r = int(config[CONF_RED] * 255) @@ -96,7 +99,7 @@ def from_rgbw(config): return (r, g, b, w) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_HEX in config: r, g, b = config[CONF_HEX] w = 0 diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 5a26ceab50..a2e2a87ed0 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE +from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] MULTI_CONF = True @@ -35,7 +36,7 @@ ds248x_ns = cg.esphome_ns.namespace("ds248x") DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) -def _component_schema(*extras): +def _component_schema(*extras: dict) -> cv.Schema: schema = cv.Schema( { cv.GenerateID(): cv.declare_id(DS248xComponent), @@ -79,11 +80,11 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def get_channel_count(config): +def get_channel_count(config: ConfigType) -> int: return CHANNEL_COUNTS[config[CONF_TYPE]] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index 19861eae36..b028958132 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -12,6 +12,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: """Validate that the channel is within the parent's channel count.""" fconf = fv.full_config.get() path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1] @@ -47,7 +48,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3f83578926..7dde794f0b 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -143,8 +144,11 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def emontx_send_command_action_to_code( - config: ConfigType, action_id, template_arg, args -) -> None: + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index 878f27bc44..f98af3f863 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/gdk101/binary_sensor.py b/esphome/components/gdk101/binary_sensor.py index a80487977f..14f5fa0e1c 100644 --- a/esphome/components/gdk101/binary_sensor.py +++ b/esphome/components/gdk101/binary_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ICON_VIBRATE, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await binary_sensor.new_binary_sensor(config[CONF_VIBRATIONS]) cg.add(hub.set_vibration_binary_sensor(var)) diff --git a/esphome/components/gdk101/sensor.py b/esphome/components/gdk101/sensor.py index 6cf89e0fd4..4ed081a7be 100644 --- a/esphome/components/gdk101/sensor.py +++ b/esphome/components/gdk101/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_MICROSILVERTS_PER_HOUR, UNIT_SECOND, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) if radiation_dose_per_1m := config.get(CONF_RADIATION_DOSE_PER_1M): diff --git a/esphome/components/gdk101/text_sensor.py b/esphome/components/gdk101/text_sensor.py index 703e68493a..bdef2466df 100644 --- a/esphome/components/gdk101/text_sensor.py +++ b/esphome/components/gdk101/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await text_sensor.new_text_sensor(config[CONF_VERSION]) cg.add(hub.set_fw_version_text_sensor(var)) diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 29b428e310..616162eb40 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,6 +12,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -47,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -73,7 +76,12 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def hc8_calibration_to_code(config, action_id, template_arg, args): +async def hc8_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) diff --git a/esphome/components/lcd_base/__init__.py b/esphome/components/lcd_base/__init__.py index bf1072ce66..08ec395720 100644 --- a/esphome/components/lcd_base/__init__.py +++ b/esphome/components/lcd_base/__init__.py @@ -1,7 +1,11 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_DIMENSIONS, CONF_POSITION +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_USER_CHARACTERS = "user_characters" @@ -9,7 +13,7 @@ lcd_base_ns = cg.esphome_ns.namespace("lcd_base") LCDDisplay = lcd_base_ns.class_("LCDDisplay", cg.PollingComponent) -def validate_lcd_dimensions(value): +def validate_lcd_dimensions(value: Any) -> list[int]: value = cv.dimensions(value) if value[0] > 0x40: raise cv.Invalid("LCD displays can't have more than 64 columns") @@ -18,7 +22,7 @@ def validate_lcd_dimensions(value): return value -def validate_user_characters(value): +def validate_user_characters(value: list[ConfigType]) -> list[ConfigType]: positions = set() for conf in value: if conf[CONF_POSITION] in positions: @@ -51,7 +55,7 @@ LCD_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def setup_lcd_display(var, config): +async def setup_lcd_display(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) if CONF_USER_CHARACTERS in config: diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 6f71530aaf..716ccfad2b 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] @@ -21,7 +24,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -40,7 +43,12 @@ async def to_code(config): ), synchronous=True, ) -async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): +async def libretiny_pwm_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 76eabc2b71..0f42083cb5 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_helpers import gpio_pin_expression +from esphome.types import ConfigType CODEOWNERS = ["@max246"] @@ -57,7 +60,12 @@ LIGHTWAVE_SEND_SCHEMA = cv.Any( LIGHTWAVE_SEND_SCHEMA, synchronous=True, ) -async def send_raw_to_code(config, action_id, template_arg, args): +async def send_raw_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -71,7 +79,7 @@ async def send_raw_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index ebb045dfce..67fb8aa5b7 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +53,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -74,6 +77,11 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True ) -async def max17043_sleep_mode_to_code(config, action_id, template_arg, args): +async def max17043_sleep_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 9798c1c297..415ae09daf 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, ICON_SCALE, STATE_CLASS_MEASUREMENT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@cujomalainey"] DEPENDENCIES = ["i2c"] @@ -93,7 +96,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -131,7 +134,12 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -async def nau7802_calibrate_to_code(config, action_id, template_arg, args): +async def nau7802_calibrate_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index dd7d1bd35d..6c2cb69990 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -1,4 +1,5 @@ from math import log +from typing import Any import esphome.codegen as cg from esphome.components import sensor @@ -15,6 +16,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType ntc_ns = cg.esphome_ns.namespace("ntc") NTC = ntc_ns.class_("NTC", cg.Component, sensor.Sensor) @@ -25,7 +27,7 @@ CONF_C = "c" ZERO_POINT = 273.15 -def validate_calibration_parameter(value): +def validate_calibration_parameter(value: Any) -> ConfigType: if isinstance(value, dict): return cv.Schema( { @@ -48,7 +50,7 @@ def validate_calibration_parameter(value): ) -def calc_steinhart_hart(value): +def calc_steinhart_hart(value: list[ConfigType]) -> tuple[float, float, float]: r1 = value[0][CONF_VALUE] r2 = value[1][CONF_VALUE] r3 = value[2][CONF_VALUE] @@ -73,7 +75,7 @@ def calc_steinhart_hart(value): return a, b, c -def calc_b(value): +def calc_b(value: ConfigType) -> tuple[float, float, float]: beta = value[CONF_B_CONSTANT] t0 = value[CONF_REFERENCE_TEMPERATURE] + ZERO_POINT r0 = value[CONF_REFERENCE_RESISTANCE] @@ -85,7 +87,7 @@ def calc_b(value): return a, b, c -def process_calibration(value): +def process_calibration(value: Any) -> ConfigType: if isinstance(value, dict): value = cv.Schema( { @@ -132,7 +134,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/openthread_info/sensor.py b/esphome/components/openthread_info/sensor.py index 4d5b3d54f4..e77b84e17c 100644 --- a/esphome/components/openthread_info/sensor.py +++ b/esphome/components/openthread_info/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, UNIT_EMPTY, ) +from esphome.types import ConfigType CONF_PARENT_AVERAGE_RSSI = "parent_average_rssi" CONF_PARENT_LAST_RSSI = "parent_last_rssi" @@ -166,13 +167,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config: dict, key: str): +async def setup_conf(config: dict, key: str) -> None: if conf := config.get(key): var = await sensor.new_sensor(conf) await cg.register_component(var, conf) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_PARENT_AVERAGE_RSSI) await setup_conf(config, CONF_PARENT_LAST_RSSI) await setup_conf(config, CONF_PARENT_LINK_QUALITY_IN) diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index b672831bf0..da789ae706 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -8,6 +8,7 @@ from esphome.components.openthread.const import ( ) import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_IP_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType CONF_ROLE = "role" CONF_RLOC16 = "rloc16" @@ -86,13 +87,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config: dict, key: str): +async def setup_conf(config: dict, key: str) -> None: if conf := config.get(key): var = await text_sensor.new_text_sensor(conf) await cg.register_component(var, conf) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_IP_ADDRESS) await setup_conf(config, CONF_ROLE) await setup_conf(config, CONF_RLOC16) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 0a11120bf0..fe784c5ffe 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv @@ -32,6 +34,8 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PERCENT, ) +from esphome.core import TimePeriodMilliseconds +from esphome.types import ConfigType CODEOWNERS = ["@ximex"] DEPENDENCIES = ["uart"] @@ -167,14 +171,14 @@ SENSORS_TO_TYPE = { } -def validate_pmsx003_sensors(value): +def validate_pmsx003_sensors(value: ConfigType) -> ConfigType: for key, types in SENSORS_TO_TYPE.items(): if key in value and value[CONF_TYPE] not in types: raise cv.Invalid(f"{value[CONF_TYPE]} does not have {key} sensor!") return value -def validate_update_interval(value): +def validate_update_interval(value: Any) -> TimePeriodMilliseconds: value = cv.positive_time_period_milliseconds(value) if value == cv.time_period("0s"): return value @@ -295,7 +299,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: require_tx = config[CONF_UPDATE_INTERVAL] > cv.time_period("0s") schema = uart.final_validate_device_schema( "pmsx003", baud_rate=9600, require_rx=True, require_tx=require_tx @@ -306,7 +310,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 5bb734cb2d..f093262e18 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,6 +26,8 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -93,7 +95,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -105,7 +112,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b2c7c3a29d..b9f7246b72 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,6 +20,8 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["modbus"] @@ -75,7 +77,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -87,7 +94,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index ad9c4b5a18..6e8c73d331 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base @@ -21,6 +23,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType CONF_FILTER_SYMBOLS = "filter_symbols" CONF_RECEIVE_SYMBOLS = "receive_symbols" @@ -62,7 +65,7 @@ RemoteReceiverComponent = remote_receiver_ns.class_( ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in esp32_rmt.VARIANTS_NO_RMT: @@ -78,7 +81,7 @@ def validate_config(config): return config -def validate_tolerance(value): +def validate_tolerance(value: Any) -> ConfigType: if isinstance(value, dict): return TOLERANCE_SCHEMA(value) @@ -196,7 +199,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) if CORE.is_esp32 and esp32.get_esp32_variant() not in esp32_rmt.VARIANTS_NO_RMT: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) diff --git a/esphome/components/remote_receiver/binary_sensor.py b/esphome/components/remote_receiver/binary_sensor.py index fe3e2af950..d4009f396b 100644 --- a/esphome/components/remote_receiver/binary_sensor.py +++ b/esphome/components/remote_receiver/binary_sensor.py @@ -1,4 +1,5 @@ from esphome.components import binary_sensor, remote_base +from esphome.types import ConfigType from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import @@ -7,6 +8,6 @@ DEPENDENCIES = ["remote_receiver"] CONFIG_SCHEMA = remote_base.validate_binary_sensor -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await remote_base.build_binary_sensor(config) await binary_sensor.register_binary_sensor(var, config) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index f60e913a0c..37789100f7 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -22,6 +22,9 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -82,7 +85,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -131,8 +134,11 @@ async def to_code(config): synchronous=True, ) async def scd30_force_recalibration_with_reference_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint16) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 277648137a..82368a60d0 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -11,6 +11,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -62,7 +65,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -109,6 +112,11 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def senseair_action_to_code(config, action_id, template_arg, args): +async def senseair_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index d25e883fa1..07ca5bf444 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -1,10 +1,12 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -46,14 +48,14 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -def obis_code(value): +def obis_code(value: Any) -> str: value = cv.string(value) match = re.match(r"^\d{1,3}-\d{1,3}:\d{1,3}\.\d{1,3}\.\d{1,3}$", value) if match is None: diff --git a/esphome/components/sml/sensor/__init__.py b/esphome/components/sml/sensor/__init__.py index e6d7180f17..64ac9773c6 100644 --- a/esphome/components/sml/sensor/__init__.py +++ b/esphome/components/sml/sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_OBIS_CODE, CONF_SERVER_ID, CONF_SML_ID, Sml, obis_code, sml_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_SERVER_ID], config[CONF_OBIS_CODE] ) diff --git a/esphome/components/sml/text_sensor/__init__.py b/esphome/components/sml/text_sensor/__init__.py index 5a5ab658c4..feff4ef256 100644 --- a/esphome/components/sml/text_sensor/__init__.py +++ b/esphome/components/sml/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_FORMAT +from esphome.types import ConfigType from .. import CONF_OBIS_CODE, CONF_SERVER_ID, CONF_SML_ID, Sml, obis_code, sml_ns @@ -33,7 +34,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor( config, config[CONF_SERVER_ID], diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 26e5c03802..367b65176b 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_OUTPUT, CONF_TYPE, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -65,7 +67,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if config[CONF_TYPE] == TYPE_GPIO: @@ -84,7 +86,7 @@ async def to_code(config): cg.add(var.set_sr_count(config[CONF_SR_COUNT])) -def _validate_output_mode(value): +def _validate_output_mode(value: ConfigType) -> ConfigType: if value.get(CONF_OUTPUT) is not True: raise cv.Invalid("Only output mode is supported") return value @@ -103,7 +105,9 @@ SN74HC595_PIN_SCHEMA = pins.gpio_base_schema( ) -def sn74hc595_pin_final_validate(pin_config, parent_config): +def sn74hc595_pin_final_validate( + pin_config: ConfigType, parent_config: ConfigType +) -> None: max_pins = parent_config[CONF_SR_COUNT] * 8 if pin_config[CONF_NUMBER] >= max_pins: raise cv.Invalid(f"Pin number must be less than {max_pins}") @@ -112,7 +116,7 @@ def sn74hc595_pin_final_validate(pin_config, parent_config): @pins.PIN_SCHEMA_REGISTRY.register( CONF_SN74HC595, SN74HC595_PIN_SCHEMA, sn74hc595_pin_final_validate ) -async def sn74hc595_pin_to_code(config): +async def sn74hc595_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_SN74HC595]) diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index 97d09aad81..c995c2c087 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PASCAL, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@danielkent-net"] @@ -55,7 +57,7 @@ OVERSAMPLING_OPTIONS = { SPA06Component = spa06_ns.class_("SPA06Component", cg.PollingComponent) -def spa_oversample_time(oversample): +def spa_oversample_time(oversample: str) -> float: # Pressure oversampling conversion times are listed on datasheet Pg. 26 # Datasheet does not have a table for temperature oversampling; # assumption is that it is the same as pressure @@ -72,7 +74,7 @@ def spa_oversample_time(oversample): return OVERSAMPLING_CONVERSION_TIMES[oversample] -def spa_sample_rate(rate): +def spa_sample_rate(rate: str) -> float: SAMPLE_RATE_OPTIONS_HZ = { "1": 1.0, "2": 2.0, @@ -94,7 +96,7 @@ def spa_sample_rate(rate): return SAMPLE_RATE_OPTIONS_HZ[rate] -def compute_measurement_conversion_time(config): +def compute_measurement_conversion_time(config: ConfigType) -> int: # - adds up sensor conversion time based on temperature and pressure oversampling rates given in datasheet # - returns a rounded up time in ms @@ -115,7 +117,7 @@ def compute_measurement_conversion_time(config): return math.ceil(1.05 * (pressure_conversion_time + temperature_conversion_time)) -def measurement_timing_check(config): +def measurement_timing_check(config: ConfigType) -> ConfigType: temp_time = 0.0 if temperature_config := config.get(CONF_TEMPERATURE): @@ -176,7 +178,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( CONFIG_SCHEMA_BASE.add_extra(measurement_timing_check) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if temperature_config := config.get(CONF_TEMPERATURE): diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 2390d046e4..cb9d64aee7 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] DEPENDENCIES = ["i2c"] @@ -48,7 +49,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_ENABLE_STATUS_LED], diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index 132b282051..c95850aadc 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_POWER +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if vbus_connected_config := config.get(CONF_VBUS_CONNECTED): diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index e6ee9d1337..8f8090b6ee 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIAMP, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -71,7 +72,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if vbus_voltage_config := config.get(CONF_VBUS_VOLTAGE): diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index 2a4eb90811..03a55393b9 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SY6970_ID]) if bus_status_config := config.get(CONF_BUS_STATUS): diff --git a/esphome/components/tm1638/binary_sensor/__init__.py b/esphome/components/tm1638/binary_sensor/__init__.py index de6ea35e54..4f89b7bf5e 100644 --- a/esphome/components/tm1638/binary_sensor/__init__.py +++ b/esphome/components/tm1638/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_KEY +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -15,7 +16,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) cg.add(var.set_keycode(config[CONF_KEY])) hub = await cg.get_variable(config[CONF_TM1638_ID]) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index 14b70be94d..d6491129c6 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_LAMBDA, CONF_STB_PIN, ) +from esphome.types import ConfigType CODEOWNERS = ["@skykingjwc"] @@ -31,7 +32,7 @@ CONFIG_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output/__init__.py index b16b08d504..961abfee47 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_LED +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -17,7 +18,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index 90ff87938c..f42b835e03 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_LED +from esphome.types import ConfigType from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 9588b0df7f..093408e868 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@kroimon"] @@ -61,7 +63,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -74,7 +76,7 @@ async def to_code(config): cg.add(var.set_time_device_address(time_device_address)) -async def register_uponor_smatrix_device(var, config): +async def register_uponor_smatrix_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_UPONOR_SMATRIX_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/uponor_smatrix/climate/__init__.py b/esphome/components/uponor_smatrix/climate/__init__.py index 47495fde9a..e80f59df24 100644 --- a/esphome/components/uponor_smatrix/climate/__init__.py +++ b/esphome/components/uponor_smatrix/climate/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate +from esphome.types import ConfigType from .. import ( UPONOR_SMATRIX_DEVICE_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = climate.climate_schema(UponorSmatrixClimate).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) diff --git a/esphome/components/uponor_smatrix/sensor/__init__.py b/esphome/components/uponor_smatrix/sensor/__init__.py index f2b34538ba..52e755f005 100644 --- a/esphome/components/uponor_smatrix/sensor/__init__.py +++ b/esphome/components/uponor_smatrix/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import ( UPONOR_SMATRIX_DEVICE_SCHEMA, @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.COMPONENT_SCHEMA.extend( ).extend(UPONOR_SMATRIX_DEVICE_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) diff --git a/esphome/components/vl53l0x/sensor.py b/esphome/components/vl53l0x/sensor.py index 583d6ccca9..3029e0f77b 100644 --- a/esphome/components/vl53l0x/sensor.py +++ b/esphome/components/vl53l0x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c, sensor @@ -10,6 +12,8 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.core import TimePeriodMicroseconds +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -23,7 +27,7 @@ CONF_LONG_RANGE = "long_range" CONF_TIMING_BUDGET = "timing_budget" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if obj[CONF_ADDRESS] != 0x29 and CONF_ENABLE_PIN not in obj: msg = "Address other then 0x29 requires enable_pin definition to allow sensor\r" msg += "re-addressing. Also if you have more then one VL53 device on the same\r" @@ -32,7 +36,7 @@ def check_keys(obj): return obj -def check_timeout(value): +def check_timeout(value: Any) -> TimePeriodMicroseconds: value = cv.positive_time_period_microseconds(value) if value.total_seconds > 60: raise cv.Invalid("Maximum timeout can not be greater then 60 seconds") @@ -70,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_signal_rate_limit(config[CONF_SIGNAL_RATE_LIMIT])) diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index bc80f167ef..8f0cf4ba33 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] AUTO_LOAD = ["uart"] @@ -26,7 +28,7 @@ WeikaiComponent = weikai_ns.class_("WeikaiComponent", cg.Component) WeikaiChannel = weikai_ns.class_("WeikaiChannel", uart.UARTComponent) -def check_channel_max(value, max): +def check_channel_max(value: ConfigType, max: int) -> ConfigType: channel_uniq = [] channel_dup = [] for x in value[CONF_UART]: @@ -41,11 +43,11 @@ def check_channel_max(value, max): return value -def check_channel_max_4(value): +def check_channel_max_4(value: ConfigType) -> ConfigType: return check_channel_max(value, 4) -def check_channel_max_2(value): +def check_channel_max_2(value: ConfigType) -> ConfigType: return check_channel_max(value, 2) @@ -70,7 +72,7 @@ WKBASE_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def register_weikai(var, config): +async def register_weikai(var: MockObj, config: ConfigType) -> None: """Register an weikai device with the given config.""" cg.add(var.set_crystal(config[CONF_CRYSTAL])) cg.add(var.set_test_mode(config[CONF_TEST_MODE])) @@ -85,7 +87,7 @@ async def register_weikai(var, config): cg.add(chan.set_parity(uart_elem[CONF_PARITY])) -def validate_pin_mode(value): +def validate_pin_mode(value: ConfigType) -> ConfigType: """Checks input/output mode inconsistency""" if not (value[CONF_MODE][CONF_INPUT] or value[CONF_MODE][CONF_OUTPUT]): raise cv.Invalid("Mode must be either input or output") diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 658137d1a2..463b9c0887 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -3,7 +3,9 @@ import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -32,7 +34,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT", True) zephyr_add_prj_conf("BT_PERIPHERAL", True) @@ -65,7 +67,12 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, synchronous=True, ) -async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): +async def numeric_comparison_reply_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) From 12da2140cf93273875315823ba041eb4b5841ade Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:57:14 +1200 Subject: [PATCH 310/597] [core] Add type annotations to component Python (11/11) (#18348) --- esphome/components/animation/image.py | 9 ++++++++- esphome/components/apds9960/__init__.py | 3 ++- esphome/components/apds9960/binary_sensor.py | 3 ++- esphome/components/apds9960/sensor.py | 3 ++- esphome/components/emc2101/__init__.py | 3 ++- esphome/components/emc2101/output/__init__.py | 3 ++- esphome/components/emc2101/sensor/__init__.py | 3 ++- esphome/components/graph/__init__.py | 9 ++++++--- esphome/components/pylontech/__init__.py | 3 ++- esphome/components/pylontech/sensor/__init__.py | 3 ++- esphome/components/pylontech/text_sensor/__init__.py | 3 ++- esphome/components/rd03d/__init__.py | 3 ++- esphome/components/rd03d/binary_sensor.py | 3 ++- esphome/components/rd03d/sensor.py | 3 ++- esphome/components/sun_gtil2/__init__.py | 3 ++- esphome/components/sun_gtil2/sensor.py | 3 ++- esphome/components/sun_gtil2/text_sensor.py | 3 ++- esphome/components/teleinfo/__init__.py | 3 ++- esphome/components/teleinfo/sensor/__init__.py | 3 ++- esphome/components/teleinfo/text_sensor/__init__.py | 3 ++- esphome/components/ufm01/__init__.py | 3 ++- esphome/components/ufm01/binary_sensor.py | 3 ++- esphome/components/ufm01/sensor.py | 3 ++- esphome/components/xl9535/__init__.py | 10 ++++++---- 24 files changed, 62 insertions(+), 29 deletions(-) diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 73d428bd20..0265a350f7 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -6,6 +6,8 @@ from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REPEAT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] @@ -79,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema( @automation.register_action( "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True ) -async def animation_action_to_code(config, action_id, template_arg, args): +async def animation_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 99e37d3764..7ac1e5eb32 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -57,7 +58,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/apds9960/binary_sensor.py b/esphome/components/apds9960/binary_sensor.py index 48e923ab2b..342f688249 100644 --- a/esphome/components/apds9960/binary_sensor.py +++ b/esphome/components/apds9960/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await binary_sensor.new_binary_sensor(config) func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor") diff --git a/esphome/components/apds9960/sensor.py b/esphome/components/apds9960/sensor.py index 468eb0995f..a75fb79d1b 100644 --- a/esphome/components/apds9960/sensor.py +++ b/esphome/components/apds9960/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await sensor.new_sensor(config) func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor") diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 323195e99a..639847345f 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -68,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/emc2101/output/__init__.py b/esphome/components/emc2101/output/__init__.py index 586f0800a6..a8820345e2 100644 --- a/esphome/components/emc2101/output/__init__.py +++ b/esphome/components/emc2101/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await output.register_output(var, config) diff --git a/esphome/components/emc2101/sensor/__init__.py b/esphome/components/emc2101/sensor/__init__.py index b6a2c8a333..cc8901cf38 100644 --- a/esphome/components/emc2101/sensor/__init__.py +++ b/esphome/components/emc2101/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -53,7 +54,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ).extend(cv.polling_component_schema("60s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 0749d7e2a3..1b99491f9c 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_X_GRID, CONF_Y_GRID, ) +from esphome.types import ConfigType CODEOWNERS = ["@synco"] @@ -115,7 +116,9 @@ GRAPH_SCHEMA = cv.Schema( ) -def _relocate_fields_to_subfolder(config, subfolder, subschema): +def _relocate_fields_to_subfolder( + config: ConfigType, subfolder: str, subschema: cv.Schema +) -> ConfigType: fields = [k.schema for k in subschema.schema] fields.remove(CONF_ID) if subfolder in config: @@ -138,7 +141,7 @@ def _relocate_fields_to_subfolder(config, subfolder, subschema): return config -def _relocate_trace(config): +def _relocate_trace(config: ConfigType) -> ConfigType: return _relocate_fields_to_subfolder(config, CONF_TRACES, GRAPH_TRACE_SCHEMA) @@ -148,7 +151,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) cg.add(var.set_width(config[CONF_WIDTH])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 82b98654a2..4ab606d9f9 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -4,6 +4,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,7 +42,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 450f663274..40391206fb 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns @@ -90,7 +91,7 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index f68ca10374..511eb7d542 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( ).extend({cv.Optional(marker): text_sensor.text_sensor_schema() for marker in MARKERS}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 52e9a2c09a..4fff41e4f6 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["uart"] @@ -38,7 +39,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/rd03d/binary_sensor.py b/esphome/components/rd03d/binary_sensor.py index afb7527aa1..2c040d0560 100644 --- a/esphome/components/rd03d/binary_sensor.py +++ b/esphome/components/rd03d/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import CONF_RD03D_ID, RD03DComponent @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) if target_config := config.get(CONF_TARGET): diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index 953d99c2da..d29656bab0 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_RD03D_ID, RD03DComponent @@ -75,7 +76,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index c7082794db..0f5ae27753 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] MULTI_CONF = True @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index 55c8195391..26435cfa67 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component @@ -73,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) if ac_voltage_config := config.get(CONF_AC_VOLTAGE): sens = await sensor.new_sensor(ac_voltage_config) diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index f74f89b3b4..eae69fb4df 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATE +from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) if state_config := config.get(CONF_STATE): sens = await text_sensor.new_text_sensor(state_config) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 87c7b9e85c..f9233511e1 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@0hax"] MULTI_CONF = True @@ -34,7 +35,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index 150484d97a..b51d4cb795 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ).extend(TELEINFO_LISTENER_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/teleinfo/text_sensor/__init__.py b/esphome/components/teleinfo/text_sensor/__init__.py index 79fabd10d0..0b6ff11d74 100644 --- a/esphome/components/teleinfo/text_sensor/__init__.py +++ b/esphome/components/teleinfo/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns @@ -13,7 +14,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TeleInfoTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await text_sensor.register_text_sensor(var, config) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 51cf3cfd91..ca0ea57796 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -34,7 +35,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py index 92ae585d96..59583357e4 100644 --- a/esphome/components/ufm01/binary_sensor.py +++ b/esphome/components/ufm01/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import CONF_UFM01_ID, UFM01Component @@ -32,7 +33,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py index 4dcd7ceebe..e3281f0b2d 100644 --- a/esphome/components/ufm01/sensor.py +++ b/esphome/components/ufm01/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CUBIC_METER_PER_HOUR, UNIT_LITRE, ) +from esphome.types import ConfigType from . import CONF_UFM01_ID, UFM01Component @@ -47,7 +48,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) if CONF_ACCUMULATED_FLOW in config: diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 58ce4a30f8..5686b74173 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_XL9535 = "xl9535" @@ -29,13 +31,13 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) -def validate_mode(mode): +def validate_mode(mode: ConfigType) -> ConfigType: if not (mode[CONF_INPUT] or mode[CONF_OUTPUT]) or ( mode[CONF_INPUT] and mode[CONF_OUTPUT] ): @@ -43,7 +45,7 @@ def validate_mode(mode): return mode -def validate_pin(pin): +def validate_pin(pin: int) -> int: if pin in (8, 9): raise cv.Invalid(f"pin {pin} doesn't exist") return pin @@ -67,7 +69,7 @@ XL9535_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_XL9535, XL9535_PIN_SCHEMA) -async def xl9535_pin_to_code(config): +async def xl9535_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_XL9535]) From 44dcd82d78bf4da00ac00f1738d9b95e9735df9e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:58:11 +1200 Subject: [PATCH 311/597] [mipi_spi] Toggle D/C only while holding the SPI bus (#18529) --- esphome/components/mipi_spi/mipi_spi.h | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index b269f46dc9..2552451bd7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,33 +246,36 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - this->dc_pin_->digital_write(false); + // Toggle D/C only while holding the bus; on boards where D/C doubles as + // another bus signal, driving it while another device owns the bus + // corrupts that device's transfer. this->enable(); + this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_array(bytes, len); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); for (size_t i = 0; i != len; i++) { this->enable(); this->write_byte(0); From edd4a86d14a0d4ac64b1b6efc2bcb00cee0d5111 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:15 -0500 Subject: [PATCH 312/597] Bump prek from 0.4.13 to 0.4.14 (#18563) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index cedc107b17..079c375c01 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.16.3 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.4.13 # also change in .github/workflows/ci.yml when updating +prek==0.4.14 # also change in .github/workflows/ci.yml when updating # Unit tests pytest==9.1.1 From 4cfa4893ef4bde01ab73da64470f83eeef2e7461 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:24:28 -0500 Subject: [PATCH 313/597] Bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the docker-actions group (#18564) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 71dedd65aa..f3f7cb30eb 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Determine tag and whether to push id: tag @@ -153,7 +153,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to the GitHub container registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10b28ace38..d0dee8165c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From ece90ee97b11ecfeff40a3c2da11cf357cf381f7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:59:30 -0500 Subject: [PATCH 314/597] Bump bundled esphome-device-builder to 1.12.2 (#18573) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 55aa0ac982..2bbe5331e5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.2 RUN \ platformio settings set enable_telemetry No \ From 5177972c041d75bf00351c7f6d2cb250253c19e5 Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:27:44 +0200 Subject: [PATCH 315/597] [runtime_image] keep decoder allocated (#18488) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 2 +- esphome/components/runtime_image/__init__.py | 5 + .../components/runtime_image/bmp_decoder.cpp | 21 +- .../components/runtime_image/bmp_decoder.h | 12 +- .../components/runtime_image/image_decoder.h | 34 +- .../components/runtime_image/image_format.h | 19 + .../components/runtime_image/jpeg_decoder.cpp | 6 - .../components/runtime_image/jpeg_decoder.h | 3 +- .../components/runtime_image/png_decoder.cpp | 7 +- .../components/runtime_image/png_decoder.h | 8 + .../runtime_image/runtime_image.cpp | 53 +-- .../components/runtime_image/runtime_image.h | 34 +- .../sendspin/image/sendspin_image.cpp | 4 +- tests/components/runtime_image/__init__.py | 15 + .../runtime_image/test_decoder_reuse.cpp | 336 ++++++++++++++++++ 15 files changed, 487 insertions(+), 72 deletions(-) create mode 100644 esphome/components/runtime_image/image_format.h create mode 100644 tests/components/runtime_image/__init__.py create mode 100644 tests/components/runtime_image/test_decoder_reuse.cpp diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 22bce4cc41..fe4f727cd6 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -216,7 +216,7 @@ void OnlineImage::loop() { } void OnlineImage::end_connection_() { - // Abort any in-progress decode to free decoder resources. + // Abort any in-progress decode; the decoder object is kept warm for the next decode. // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release(). if (this->is_decoding()) { RuntimeImage::release(); diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 9fa32a5a65..3c130a7d75 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -77,6 +77,11 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_host: + # JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only + # predefines the lowercase __linux__; without this a Linux host + # build tries to include Arduino.h. + cg.add_build_flag("-D__LINUX__") if CORE.is_esp32: from esphome.components.esp32 import add_idf_component diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 6a1bd61d86..5d45621fb7 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -12,6 +12,22 @@ namespace esphome::runtime_image { static const char *const TAG = "image_decoder.bmp"; +void BmpDecoder::reset() { + ImageDecoder::reset(); + this->bits_per_pixel_ = 0; + this->compression_method_ = 0; + this->image_data_size_ = 0; + this->width_ = 0; + this->height_ = 0; + this->current_index_ = 0; + this->paint_index_ = 0; + // color_table_ is deliberately kept allocated so the next decode can reuse it + this->color_table_entries_ = 0; + this->data_offset_ = 0; + this->padding_bytes_ = 0; + this->width_bytes_ = 0; +} + int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t index = 0; if (this->current_index_ == 0) { @@ -85,7 +101,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t header_size = encode_uint32(buffer[17], buffer[16], buffer[15], buffer[14]); size_t offset = 14 + header_size; - this->color_table_ = std::make_unique(this->color_table_entries_); + if (this->color_table_entries_ > this->color_table_capacity_) { + this->color_table_ = std::make_unique(this->color_table_entries_); + this->color_table_capacity_ = this->color_table_entries_; + } for (size_t i = 0; i < this->color_table_entries_; i++) { this->color_table_[i] = encode_uint32(buffer[offset + i * 4 + 3], buffer[offset + i * 4 + 2], diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index a52a561584..01acc41f91 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -21,8 +21,9 @@ class BmpDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - BmpDecoder(RuntimeImage *image) : ImageDecoder(image) {} + BmpDecoder(RuntimeImage *image) : ImageDecoder(image, BMP) {} + void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { @@ -35,17 +36,18 @@ class BmpDecoder : public ImageDecoder { } protected: + std::unique_ptr color_table_; size_t current_index_{0}; size_t paint_index_{0}; ssize_t width_{0}; ssize_t height_{0}; - uint16_t bits_per_pixel_{0}; + size_t width_bytes_{0}; + size_t data_offset_{0}; uint32_t compression_method_{0}; uint32_t image_data_size_{0}; uint32_t color_table_entries_{0}; - std::unique_ptr color_table_; - size_t width_bytes_{0}; - size_t data_offset_{0}; + uint32_t color_table_capacity_{0}; // Allocated entries in color_table_, kept across decodes + uint16_t bits_per_pixel_{0}; uint8_t padding_bytes_{0}; }; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 6d351a10aa..2a8b393888 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -1,5 +1,6 @@ #pragma once #include "esphome/core/color.h" +#include "image_format.h" namespace esphome::runtime_image { @@ -36,18 +37,41 @@ class ImageDecoder { * @brief Construct a new Image Decoder object * * @param image The RuntimeImage to decode the stream into. + * @param format The image format this decoder handles. */ - ImageDecoder(RuntimeImage *image) : image_(image) {} + ImageDecoder(RuntimeImage *image, ImageFormat format) : image_(image), format_(format) {} virtual ~ImageDecoder() = default; + /// @brief Get the image format handled by this decoder. + ImageFormat get_format() const { return this->format_; } + + /// @brief Check if a decoding session is in progress (prepare() called, reset() not yet). + bool is_active() const { return this->active_; } + /** - * @brief Initialize the decoder. + * @brief Reset the decoder state, ending any decoding session. + * Subclasses should override this method to reset any format-specific state. + * Buffers the next decode can reuse should be kept allocated to avoid heap churn. + */ + virtual void reset() { + this->active_ = false; + this->expected_size_ = 0; + this->decoded_bytes_ = 0; + this->size_valid_ = true; + this->x_scale_ = 1.0; + this->y_scale_ = 1.0; + } + + /** + * @brief Initialize the decoder, starting a new decoding session. * * @param expected_size Hint about the expected data size (0 if unknown). * @return int Returns 0 on success, a {@see DecodeError} value in case of an error. */ virtual int prepare(size_t expected_size) { + this->reset(); this->expected_size_ = expected_size; + this->active_ = true; return 0; } @@ -103,11 +127,13 @@ class ImageDecoder { } protected: + double x_scale_ = 1.0; + double y_scale_ = 1.0; RuntimeImage *image_; size_t expected_size_ = 0; // Expected data size (0 if unknown) size_t decoded_bytes_ = 0; // Bytes processed so far - double x_scale_ = 1.0; - double y_scale_ = 1.0; + const ImageFormat format_; + bool active_ = false; // A decoding session is in progress bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h new file mode 100644 index 0000000000..524e52d7bc --- /dev/null +++ b/esphome/components/runtime_image/image_format.h @@ -0,0 +1,19 @@ +#pragma once + +namespace esphome::runtime_image { + +/** + * @brief Image format types that can be decoded dynamically. + */ +enum ImageFormat { + /** Automatically detect from data. Not implemented yet. */ + AUTO, + /** JPEG format. */ + JPEG, + /** PNG format. */ + PNG, + /** BMP format. */ + BMP, +}; + +} // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index c46e86fd0d..85ec945259 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -52,12 +52,6 @@ static int draw_callback(JPEGDRAW *jpeg) { return 1; } -int JpegDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); - // JPEG decoder needs complete data before decoding - return 0; -} - int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { // JPEG decoder requires complete data // If we know the expected size, wait for it diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index ed2401e263..67c9b77f4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - JpegDecoder(RuntimeImage *image) : ImageDecoder(image) {} + JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {} ~JpegDecoder() override {} - int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; protected: diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 9501702711..106f25bbe1 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -48,7 +48,7 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui } } -PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { +PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image, PNG) { { RAMAllocator allocator; pngle_t *pngle = allocator.allocate(1, PNGLE_T_SIZE); @@ -57,8 +57,8 @@ PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { return; } memset(pngle, 0, PNGLE_T_SIZE); - pngle_reset(pngle); this->pngle_ = pngle; + pngle_reset(this->pngle_); } } @@ -71,11 +71,12 @@ PngDecoder::~PngDecoder() { } int PngDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); + // Check before the base prepare() so a failure never leaves an active session if (!this->pngle_) { ESP_LOGE(TAG, "PNG decoder engine not initialized!"); return DECODE_ERROR_OUT_OF_MEMORY; } + ImageDecoder::prepare(expected_size); pngle_set_user_data(this->pngle_, this); pngle_set_init_callback(this->pngle_, init_callback); pngle_set_draw_callback(this->pngle_, draw_callback); diff --git a/esphome/components/runtime_image/png_decoder.h b/esphome/components/runtime_image/png_decoder.h index 24521d33a8..a1cd60e0a6 100644 --- a/esphome/components/runtime_image/png_decoder.h +++ b/esphome/components/runtime_image/png_decoder.h @@ -22,6 +22,14 @@ class PngDecoder : public ImageDecoder { PngDecoder(RuntimeImage *image); ~PngDecoder() override; + void reset() override { + ImageDecoder::reset(); + if (this->pngle_) { + pngle_reset(this->pngle_); + } + this->pixels_decoded_ = 0; + } + int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 8fe9be4c8c..e269f7d8f3 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -172,33 +172,38 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, } bool RuntimeImage::begin_decode(size_t expected_size) { - if (this->decoder_) { + if (this->is_decoding()) { ESP_LOGW(TAG, "Decoding already in progress"); return false; } - this->decoder_ = this->create_decoder_(); + // An idle decoder for a different format cannot be reused + if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) { + ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_); + this->decoder_ = nullptr; + } + if (!this->decoder_) { - ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); - return false; + this->decoder_ = this->create_decoder_(this->format_); + if (!this->decoder_) { + ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); + return false; + } } - this->total_size_ = expected_size; this->decoded_bytes_ = 0; - // Initialize decoder int result = this->decoder_->prepare(expected_size); if (result < 0) { ESP_LOGE(TAG, "Failed to prepare decoder: %d", result); - this->decoder_ = nullptr; + this->decoder_ = nullptr; // If prepare fails, a full reset is needed return false; } - return true; } int RuntimeImage::feed_data(uint8_t *data, size_t len) { - if (!this->decoder_) { + if (!this->is_decoding()) { ESP_LOGE(TAG, "No decoder initialized"); return -1; } @@ -212,7 +217,7 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) { } bool RuntimeImage::end_decode() { - if (!this->decoder_) { + if (!this->is_decoding()) { return false; } @@ -224,26 +229,23 @@ bool RuntimeImage::end_decode() { this->data_start_ = this->buffer_; } - // Clean up decoder - this->decoder_ = nullptr; + // End the session; the decoder object stays warm so the next decode can + // reuse it (and its buffers) without churning the heap. + this->decoder_->reset(); ESP_LOGD(TAG, "Decoding complete: %dx%d, %zu bytes", this->width_, this->height_, this->decoded_bytes_); return true; } -bool RuntimeImage::is_decode_finished() const { - if (!this->decoder_) { - return false; - } - return this->decoder_->is_finished(); -} +bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } void RuntimeImage::release() { this->release_buffer_(); - // Reset decoder separately — release() can be called from within the decoder - // (via set_size -> resize -> resize_buffer_), so we must not destroy the decoder here. - // The decoder lifecycle is managed by begin_decode()/end_decode(). - this->decoder_ = nullptr; + // End any active decode session; decoders free the format-specific working buffers + // they can (PNG), while the decoder object itself is kept warm for the next decode. + if (this->decoder_) { + this->decoder_->reset(); + } } void RuntimeImage::release_buffer_() { @@ -347,8 +349,9 @@ size_t RuntimeImage::get_buffer_size(int width, int height) const { int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } -std::unique_ptr RuntimeImage::create_decoder_() { - switch (this->format_) { +std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) { + ESP_LOGV(TAG, "Creating decoder for format %d", format); + switch (format) { #ifdef USE_RUNTIME_IMAGE_BMP case BMP: return make_unique(this); @@ -362,7 +365,7 @@ std::unique_ptr RuntimeImage::create_decoder_() { return make_unique(this); #endif default: - ESP_LOGE(TAG, "Unsupported image format: %d", this->format_); + ESP_LOGE(TAG, "Unsupported image format: %d", format); return nullptr; } } diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 10ce980be2..cfac253fdb 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -3,25 +3,11 @@ #include "esphome/components/image/image.h" #include "esphome/core/helpers.h" +#include "image_decoder.h" +#include "image_format.h" + namespace esphome::runtime_image { -// Forward declaration -class ImageDecoder; - -/** - * @brief Image format types that can be decoded dynamically. - */ -enum ImageFormat { - /** Automatically detect from data. Not implemented yet. */ - AUTO, - /** JPEG format. */ - JPEG, - /** PNG format. */ - PNG, - /** BMP format. */ - BMP, -}; - /** * @brief A dynamic image that can be loaded and decoded at runtime. * @@ -99,7 +85,7 @@ class RuntimeImage : public image::Image { /** * @brief Check if decoding is currently in progress. */ - bool is_decoding() const { return this->decoder_ != nullptr; } + bool is_decoding() const { return this->decoder_ != nullptr && this->decoder_->is_active(); } /** * @brief Check if the decoder has finished processing all data. @@ -120,9 +106,10 @@ class RuntimeImage : public image::Image { ImageFormat get_format() const { return this->format_; } /** - * @brief Release the image buffer and free memory. + * @brief Release the image buffer and free its memory, ending any decode session. * - * An external buffer is let go of rather than freed. + * An external buffer is let go of rather than freed. The decoder object is kept + * warm so the next decode can reuse it without churning the heap. */ void release(); @@ -194,9 +181,11 @@ class RuntimeImage : public image::Image { int get_position_(int x, int y) const; /** - * @brief Create decoder instance for the image's format. + * @brief Create decoder instance for the requested format. + * @param format The image format to decode. + * @return Unique pointer to the created decoder, or nullptr on failure. */ - std::unique_ptr create_decoder_(); + std::unique_ptr create_decoder_(ImageFormat format); // Memory management uint8_t *buffer_{nullptr}; @@ -224,7 +213,6 @@ class RuntimeImage : public image::Image { int buffer_height_{0}; // Decoding state - size_t total_size_{0}; size_t decoded_bytes_{0}; /** Fixed width requested on configuration, or 0 if not specified. */ diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 626d7966b7..558a292d5b 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -86,8 +86,8 @@ void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { } const bool decoded = this->decode_frame_(data, length, target); - // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is - // safe on every path. + // Ends any half-finished decode session (the decoder object is kept for reuse). An external + // buffer is let go of rather than freed, so this is safe on every path. this->decode_sink_.release(); if (!decoded) { diff --git a/tests/components/runtime_image/__init__.py b/tests/components/runtime_image/__init__.py new file mode 100644 index 0000000000..a8ff4bb68e --- /dev/null +++ b/tests/components/runtime_image/__init__.py @@ -0,0 +1,15 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in cpptest builds; formats are normally enabled by + # process_runtime_image_config(). Enable all formats so the format-switch + # tests have two decoder types and every retained decoder is under test. + async def to_code_testing(config: ConfigType) -> None: + enable_format("BMP") + enable_format("PNG") + enable_format("JPEG") + + manifest.to_code = to_code_testing diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp new file mode 100644 index 0000000000..87e77b00be --- /dev/null +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -0,0 +1,336 @@ +#include +#include + +#include +#include +#include +#include + +#include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::testing { + +// 3x2 24bpp BMP, every pixel a unique color (rows padded to 4 bytes) +static const uint8_t BMP_24BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00, + 0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00, +}; + +static const uint8_t BMP_24BPP_EXPECTED[2][3][3] = { + {{0xE0, 0x10, 0x20}, {0x30, 0xC0, 0x40}, {0x50, 0x60, 0xA0}}, + {{0x11, 0x22, 0x33}, {0x99, 0x88, 0x77}, {0xAB, 0xCD, 0xEF}}, +}; + +// 3x2 8bpp BMP with a 4-entry color table +static const uint8_t BMP_8BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x20, 0x10, 0x00, 0xD0, 0xE0, 0xF0, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x03, 0x02, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, +}; + +static const uint8_t BMP_8BPP_EXPECTED[2][3][3] = { + {{0x10, 0x20, 0x30}, {0xF0, 0xE0, 0xD0}, {0x00, 0xFF, 0x00}}, + {{0xFF, 0x00, 0xFF}, {0x00, 0xFF, 0x00}, {0xF0, 0xE0, 0xD0}}, +}; + +// 3x2 8bpp BMP with an 8-entry color table, all colors distinct from BMP_8BPP's +static const uint8_t BMP_8BPP_BIG[] = { + 0x42, 0x4D, 0x5E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x18, 0x08, + 0x00, 0xA8, 0xB8, 0xC8, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x55, 0x99, + 0x11, 0x00, 0xCC, 0x00, 0x66, 0x00, 0x44, 0x22, 0xEE, 0x00, 0x01, 0x06, 0x04, 0x00, 0x07, 0x05, 0x03, 0x00, +}; + +static const uint8_t BMP_8BPP_BIG_EXPECTED[2][3][3] = { + {{0xEE, 0x22, 0x44}, {0x11, 0x99, 0x55}, {0x80, 0xFF, 0x00}}, + {{0xC8, 0xB8, 0xA8}, {0x66, 0x00, 0xCC}, {0xFF, 0x80, 0x00}}, +}; + +// 4x4 RGB PNG, every pixel a unique color +static const uint8_t PNG_RGB[] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x04, 0x08, 0x02, 0x00, 0x00, 0x00, 0x26, 0x93, 0x09, 0x29, 0x00, 0x00, 0x00, 0x38, 0x49, + 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x16, 0x50, 0x30, 0x58, 0xB0, 0xE1, 0xC0, 0xFF, 0xFF, 0x0C, + 0x0C, 0x0E, 0x0C, 0x50, 0xEC, 0xE0, 0xE0, 0xC0, 0x50, 0xCF, 0xF0, 0x9F, 0xA1, 0xFE, 0xFF, 0xFF, 0x7A, 0x86, 0xFA, + 0xFF, 0x0C, 0x0C, 0x42, 0x26, 0x61, 0xA9, 0xCE, 0x8A, 0xFF, 0xEE, 0xEC, 0x5A, 0x7D, 0xF6, 0x3D, 0x00, 0x81, 0xCB, + 0x12, 0x4D, 0xB3, 0xFB, 0xD4, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +}; + +static const uint8_t PNG_RGB_EXPECTED[4][4][3] = { + {{0x01, 0x02, 0x03}, {0x10, 0x20, 0x30}, {0xA0, 0xB0, 0xC0}, {0xFF, 0xFF, 0x00}}, + {{0x40, 0x00, 0x00}, {0x00, 0x40, 0x00}, {0x00, 0x00, 0x40}, {0x40, 0x40, 0x40}}, + {{0x7F, 0x00, 0xFF}, {0x00, 0x7F, 0xFF}, {0xFF, 0x7F, 0x00}, {0x7F, 0xFF, 0x00}}, + {{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}}, +}; + +/// Exposes the protected decoder machinery so reuse and eviction can be observed directly. +class TestableRuntimeImage : public RuntimeImage { + public: + explicit TestableRuntimeImage(ImageFormat format) + : RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {} + + ImageDecoder *decoder() { return this->decoder_.get(); } + + /// Simulates the state a dynamic-format producer (PR #16337) would leave behind: + /// a cached decoder whose format no longer matches the image's format. + /// TODO: once #16337 adds a public way to change the format, drive the mismatch + /// through it and delete this seam. + void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); } +}; + +/// Runs one full decode session. Returns true when every stage succeeded. +static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) { + std::vector buffer(data, data + len); // feed_data needs mutable bytes + if (!img.begin_decode(len)) { + return false; + } + size_t offset = 0; + while (offset < len) { + int consumed = img.feed_data(buffer.data() + offset, len - offset); + if (consumed <= 0) { + return false; // decode error, or no progress despite full data + } + offset += consumed; + } + return img.end_decode(); +} + +/// Feeds the image the way online_image's download loop does: append a small +/// chunk to a window, feed the window, drop what was consumed, repeat. A zero +/// return mid-stream means "need more data" and grows the window. +static bool decode_chunked(TestableRuntimeImage &img, const uint8_t *data, size_t len, size_t chunk_size) { + if (!img.begin_decode(len)) { + return false; + } + std::vector window; + size_t supplied = 0; + while (supplied < len || !window.empty()) { + if (supplied < len) { + size_t take = std::min(chunk_size, len - supplied); + window.insert(window.end(), data + supplied, data + supplied + take); + supplied += take; + } + int consumed = img.feed_data(window.data(), window.size()); + if (consumed < 0 || (consumed == 0 && supplied >= len)) { + return false; // decode error, or stuck with all data supplied + } + window.erase(window.begin(), window.begin() + consumed); + } + return img.end_decode(); +} + +template static void expect_pixels(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][3]) { + ASSERT_EQ(img.get_width(), static_cast(W)); + ASSERT_EQ(img.get_height(), static_cast(H)); + for (size_t y = 0; y < H; y++) { + for (size_t x = 0; x < W; x++) { + SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")"); + Color color = img.get_pixel(x, y); + EXPECT_THAT((std::array{color.r, color.g, color.b}), ::testing::ElementsAreArray(expected[y][x])); + } + } +} + +TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated"; +} + +TEST(RuntimeImageDecoder, SecondDecodeStartsClean) { + TestableRuntimeImage img(BMP); + + // Palettized decode, then a 24bpp decode, then palettized again, all on the + // same decoder: each session must produce correct pixels for its own image. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ColorTableGrowsAndShrinksAcrossReuse) { + TestableRuntimeImage img(BMP); + + // Small palette first: the retained table is allocated at 4 entries. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Growing to 8 entries on the reused decoder must reallocate, not overflow. + ASSERT_TRUE(decode_all(img, BMP_8BPP_BIG, sizeof(BMP_8BPP_BIG))); + expect_pixels(img, BMP_8BPP_BIG_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + // Shrinking back must not surface stale colors from the larger table. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Chunked again on the warm decoder: the cross-call resume state + // (current_index_ / paint_index_) must have been fully reset. + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) { + // PNG image holding a stale BMP decoder: begin_decode must evict and recreate. + TestableRuntimeImage png_img(PNG); + png_img.plant_decoder(BMP); + ASSERT_NE(png_img.decoder(), nullptr); + ASSERT_EQ(png_img.decoder()->get_format(), BMP); + + ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB))); + EXPECT_EQ(png_img.decoder()->get_format(), PNG); + expect_pixels(png_img, PNG_RGB_EXPECTED); + + // And the other direction: BMP image holding a stale PNG decoder. + TestableRuntimeImage bmp_img(BMP); + bmp_img.plant_decoder(PNG); + ASSERT_NE(bmp_img.decoder(), nullptr); + ASSERT_EQ(bmp_img.decoder()->get_format(), PNG); + + ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP))); + EXPECT_EQ(bmp_img.decoder()->get_format(), BMP); + expect_pixels(bmp_img, BMP_24BPP_EXPECTED); +} + +TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) { + TestableRuntimeImage img(PNG); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + img.release(); + EXPECT_EQ(img.decoder(), first) << "release() must keep the decoder for reuse"; + EXPECT_FALSE(img.is_decoding()); + EXPECT_EQ(img.get_width(), 0); + EXPECT_EQ(img.get_height(), 0); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + expect_pixels(img, PNG_RGB_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FailedDecodeRecovers) { + TestableRuntimeImage img(BMP); + + uint8_t garbage[32]; + memset(garbage, 'X', sizeof(garbage)); + ASSERT_TRUE(img.begin_decode(sizeof(garbage))); + EXPECT_LT(img.feed_data(garbage, sizeof(garbage)), 0) << "garbage must fail to decode"; + img.release(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); +} + +#ifdef USE_RUNTIME_IMAGE_JPEG +// 8x8 gradient JPEG (quality 90). JPEG is lossy, so the test asserts that a +// reused decoder reproduces the exact same pixels, not absolute colors. +static const uint8_t JPEG_GRADIENT[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, + 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A, + 0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, + 0x15, 0x15, 0x0C, 0x0F, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x03, + 0x04, 0x04, 0x05, 0x04, 0x05, 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xE5, 0x3E, 0x0B, 0xFE, + 0xC8, 0x7F, 0xEA, 0x3F, 0xD0, 0xBD, 0x3F, 0x86, 0x8A, 0x28, 0xAA, 0xC2, 0x62, 0x6A, 0xFB, 0x25, 0xA9, 0xD5, 0xC0, + 0x7C, 0x6B, 0x9D, 0x7F, 0x62, 0xD3, 0xFD, 0xEF, 0xF5, 0xF7, 0x9F, 0xFF, 0xD9, +}; + +static std::vector pixel_bytes(TestableRuntimeImage &img) { + const uint8_t *start = img.get_data_start(); + return std::vector(start, start + img.get_width_stride() * img.get_height()); +} + +TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(JPEG); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(img.get_width(), 8); + ASSERT_EQ(img.get_height(), 8); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; +} +#endif // USE_RUNTIME_IMAGE_JPEG + +TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) { + TestableRuntimeImage img(BMP); + std::vector buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP)); + + ASSERT_TRUE(img.begin_decode(buffer.size())); + EXPECT_TRUE(img.is_decoding()); + EXPECT_FALSE(img.is_decode_finished()); + + ASSERT_EQ(img.feed_data(buffer.data(), buffer.size()), static_cast(buffer.size())); + EXPECT_TRUE(img.is_decode_finished()) << "all pixel data consumed"; + + ASSERT_TRUE(img.end_decode()); + EXPECT_FALSE(img.is_decoding()) << "end_decode() must close the session"; + EXPECT_FALSE(img.is_decode_finished()) << "no session means nothing is 'finished'"; +} + +} // namespace esphome::runtime_image::testing From 46f90d0c54af2ce42af6aa55dcdcdbaf2717e5ae Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:28:17 +0200 Subject: [PATCH 316/597] [core] Add portable strcasestr implementation named str_contains_ignore_case (#18497) Co-authored-by: J. Nick Koston --- esphome/components/audio/audio.cpp | 2 +- esphome/core/helpers.cpp | 13 ++++++++ esphome/core/helpers.h | 19 +++++++++++ tests/components/core/helpers_test.cpp | 46 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index b0aa3c1abb..402e741059 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -86,7 +86,7 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url) // Match "audio/ogg" with a codecs parameter containing "opus" // Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc. // Plain "audio/ogg" without opus is not matched (almost always Ogg Vorbis) - if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) { + if (strncasecmp(content_type, "audio/ogg", 9) == 0 && str_contains_ignore_case(content_type + 9, "opus")) { return AudioFileType::OPUS; } #endif diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index bd08d3b63e..a276020be4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -220,6 +220,19 @@ bool str_endswith_ignore_case(const char *str, size_t str_len, const char *suffi return strncasecmp(str + str_len - suffix_len, suffix, suffix_len) == 0; } +bool str_contains_ignore_case_fallback(const char *haystack, const char *needle) { + const size_t needle_len = strlen(needle); + if (needle_len == 0) { + return true; + } + for (const char *p = haystack; *p != '\0'; p++) { + if (strncasecmp(p, needle, needle_len) == 0) { + return true; + } + } + return false; +} + // str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 994fa2c26a..5a9c120b84 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -981,6 +981,25 @@ inline bool str_endswith_ignore_case(const std::string &str, const char *suffix) return str_endswith_ignore_case(str.c_str(), str.size(), suffix, strlen(suffix)); } +/// Fallback implementation for case insensitive substring comparison. +bool str_contains_ignore_case_fallback(const char *haystack, const char *needle); + +/// Case-insensitive check if needle string is contained in haystack (no heap allocation). +inline bool str_contains_ignore_case(const char *haystack, const char *needle) { + if (!needle || !haystack) { + return false; + } + +// strcasestr is a GNU extension: newlib only declares it when _GNU_SOURCE is set. +// ESP32/ESP8266/host builds get it from their framework or from g++ on Linux; +// LibreTiny, RP2 and Zephyr do not, so they use the hand-rolled fallback. +#if defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) + return str_contains_ignore_case_fallback(haystack, needle); +#else // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) + return strcasestr(haystack, needle) != nullptr; +#endif // defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ZEPHYR) +} + // str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 // str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index a9a940392f..d5219f9d47 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -83,4 +83,50 @@ TEST(StaticVectorTest, ConvertingConstructorSameSize) { EXPECT_EQ(dst[2], 3); } +TEST(StringContainsIgnoreCaseTest, NullPointerAlwaysFalse) { + const char *haystack = nullptr; + const char *needle = nullptr; + + EXPECT_FALSE(str_contains_ignore_case(haystack, needle)); + EXPECT_FALSE(str_contains_ignore_case("Hello World", needle)); + EXPECT_FALSE(str_contains_ignore_case(haystack, "anything")); +} + +TEST(StringContainsIgnoreCaseTest, EmptySearchMatches) { + const char *haystack = "Hello World"; + + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "")); +} + +TEST(StringContainsIgnoreCaseTest, MiscCaseMatches) { + const char *haystack = "Hello World"; + + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hello")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hello")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "HELLO")); + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hELLO")); +} + +TEST(StringContainsIgnoreCaseTest, MiscNotMatching) { + const char *haystack = "Hello World"; + + // Expected to match + EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hell")); + + // Expected not to match + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Heaven")); + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Hello!")); + EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "world!")); +} + +TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) { + const char *haystack = "Hello World"; + for (const char *needle : {"", "Hello", "hELLO", "Hell", "world", "Heaven", "Hello!", "d"}) { + EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle)) + << "needle: " << needle; + } + EXPECT_EQ(str_contains_ignore_case_fallback("", ""), str_contains_ignore_case("", "")); + EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc")); +} + } // namespace esphome From 52bfc0efb1c574324910c5d0c1de628a4bcc1147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 18:06:23 -0500 Subject: [PATCH 317/597] [espnow] Fix dump_config crash when enable_on_boot is false (#18572) --- esphome/components/espnow/espnow_component.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index df9a1b8668..ecf0f79e4a 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -129,14 +129,17 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int 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"); + // Only report driver details once enabled; with enable_on_boot: false the + // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // and after a failed enable_() the values would be meaningless. + if (this->state_ != ESPNOW_STATE_ENABLED) { + // OFF here means enable_() failed; the core logs the FAILED marker separately + ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } + uint32_t version = 0; + esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, From 7957808f00eec1eac78e40cd59dac8815ae7c55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:57:58 +0200 Subject: [PATCH 318/597] [emontx] Fix sensor state_class defaults not being applied correctly (#17610) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- esphome/components/emontx/sensor/__init__.py | 63 ++++++----- tests/component_tests/emontx/__init__.py | 0 .../emontx/test_sensor_defaults.py | 100 ++++++++++++++++++ tests/components/emontx/test.esp32-idf.yaml | 3 +- tests/components/emontx/test.esp8266-ard.yaml | 3 +- tests/components/emontx/test.rp2040-ard.yaml | 3 +- .../components/emontx/validate.esp32-idf.yaml | 73 +++++++++++++ 7 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/emontx/__init__.py create mode 100644 tests/component_tests/emontx/test_sensor_defaults.py create mode 100644 tests/components/emontx/validate.esp32-idf.yaml diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 83a972c5e0..967bc4e699 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -68,6 +68,7 @@ PATTERN_CONFIGS = { "PULSE": { CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, CONF_ACCURACY_DECIMALS: 0, }, "PF": { @@ -78,12 +79,13 @@ PATTERN_CONFIGS = { }, } -# Create a base schema that's flexible for any tag -BASE_SCHEMA = sensor.sensor_schema( - EmonTxSensor, - state_class=STATE_CLASS_MEASUREMENT, - accuracy_decimals=0, -).extend( +# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults. +# Passing them to sensor_schema() would register them via cv.Optional(key, default=...), +# making them always present in the validated config dict and preventing +# apply_tag_defaults from overriding them with the correct per-prefix values. +# They are injected by apply_tag_defaults below, after running through +# sensor.validate_state_class() so the value is code-generation-ready. +BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend( { cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), cv.Required(CONF_TAG_NAME): cv.string, @@ -91,34 +93,43 @@ BASE_SCHEMA = sensor.sensor_schema( ) +def _apply_defaults(config: ConfigType, defaults: dict) -> None: + """Inject defaults into config, skipping keys already set by the user. + state_class values are run through validate_state_class so they are + code-generation-ready, matching what sensor_schema() would normally do.""" + for key, value in defaults.items(): + if key not in config: + if key == CONF_STATE_CLASS: + value = sensor.validate_state_class(value) + config[key] = value + + def apply_tag_defaults(config: ConfigType) -> ConfigType: """Apply defaults based on tag prefix if applicable, but don't restrict any tags.""" tag = config[CONF_TAG_NAME] - # Skip if tag is too short - if len(tag) < 2: - return config + if len(tag) >= 2: + tag_upper = tag.upper() - # Check if this tag starts with a known prefix - tag_upper = tag.upper() + for pattern, pattern_config in PATTERN_CONFIGS.items(): + if tag_upper.startswith(pattern): + _apply_defaults(config, pattern_config) + return config - for pattern, pattern_config in PATTERN_CONFIGS.items(): - if tag_upper.startswith(pattern): - # Apply pattern defaults if not overridden by user - for key, value in pattern_config.items(): - if key not in config: - config[key] = value + # Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3) + prefix = tag_upper[0] + if prefix in SENSOR_CONFIGS and tag[1:].isdigit(): + _apply_defaults(config, SENSOR_CONFIGS[prefix]) return config - # Only apply defaults for known prefixes with numeric indices - prefix = tag_upper[0] - if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit(): - # Apply defaults for known tag types, but only if not overridden by user - defaults = SENSOR_CONFIGS[prefix] - for key, value in defaults.items(): - if key not in config: - config[key] = value - + # Fall back to generic defaults for tags with no known prefix + _apply_defaults( + config, + { + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 0, + }, + ) return config diff --git a/tests/component_tests/emontx/__init__.py b/tests/component_tests/emontx/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/emontx/test_sensor_defaults.py b/tests/component_tests/emontx/test_sensor_defaults.py new file mode 100644 index 0000000000..00d24d282e --- /dev/null +++ b/tests/component_tests/emontx/test_sensor_defaults.py @@ -0,0 +1,100 @@ +"""Tests for emontx sensor tag defaults.""" + +import pytest + +from esphome.components import sensor +from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) + + +def _resolve_via_config_schema(tag: str) -> dict: + """Run a minimal config through the real CONFIG_SCHEMA pipeline, the + same path a user's YAML goes through.""" + return CONFIG_SCHEMA( + {"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"} + ) + + +def test_config_schema_applies_tag_default_state_class(): + """If sensor_schema(state_class=...) is reintroduced, the schema-level + default wins over apply_tag_defaults' per-prefix value, and E1 would + resolve to measurement instead of total_increasing. Driving the real + CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since + sensor_schema() runs before apply_tag_defaults in the cv.All() chain. + """ + result = _resolve_via_config_schema("E1") + assert result[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_TOTAL_INCREASING + ) + + +def test_config_schema_applies_tag_default_accuracy_decimals(): + """Same root cause as the state_class regression: reintroducing + sensor_schema(accuracy_decimals=...) would make V1 resolve to the + schema-level default instead of the prefix-specific value of 2. + """ + result = _resolve_via_config_schema("V1") + assert result[CONF_ACCURACY_DECIMALS] == 2 + + +def _make_config(tag: str) -> dict: + """Minimal config dict with only tag_name set — no overrides.""" + return {"tag_name": tag} + + +@pytest.mark.parametrize( + ("tag", "expected_state_class", "expected_decimals"), + [ + # Known numeric-index prefixes + ("E1", STATE_CLASS_TOTAL_INCREASING, 0), + ("E12", STATE_CLASS_TOTAL_INCREASING, 0), + ("P1", STATE_CLASS_MEASUREMENT, 0), + ("V1", STATE_CLASS_MEASUREMENT, 2), + ("I1", STATE_CLASS_MEASUREMENT, 2), + ("T1", STATE_CLASS_MEASUREMENT, 2), + # Known patterns + ("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0), + ("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0), + ("PF1", STATE_CLASS_MEASUREMENT, 2), + # Unknown / free-form tags fall back to generic defaults + ("CUSTOM1", STATE_CLASS_MEASUREMENT, 0), + ("X", STATE_CLASS_MEASUREMENT, 0), + ], +) +def test_apply_tag_defaults(tag, expected_state_class, expected_decimals): + """apply_tag_defaults must inject the correct state_class and accuracy_decimals + for each tag type when no user overrides are present.""" + config = _make_config(tag) + result = apply_tag_defaults(config) + + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class) + assert result[CONF_ACCURACY_DECIMALS] == expected_decimals + + +@pytest.mark.parametrize( + ("tag", "user_state_class", "user_decimals"), + [ + # User overrides must not be clobbered by defaults + ("E1", STATE_CLASS_MEASUREMENT, 3), + ("PULSE1", STATE_CLASS_MEASUREMENT, 1), + ("V1", STATE_CLASS_TOTAL_INCREASING, 0), + ("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4), + ], +) +def test_apply_tag_defaults_respects_user_overrides( + tag, user_state_class, user_decimals +): + """apply_tag_defaults must not overwrite values already set by the user.""" + config = _make_config(tag) + config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class) + config[CONF_ACCURACY_DECIMALS] = user_decimals + + result = apply_tag_defaults(config) + + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class) + assert result[CONF_ACCURACY_DECIMALS] == user_decimals diff --git a/tests/components/emontx/test.esp32-idf.yaml b/tests/components/emontx/test.esp32-idf.yaml index a0784fcd53..e56b1bda5d 100644 --- a/tests/components/emontx/test.esp32-idf.yaml +++ b/tests/components/emontx/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/test.esp8266-ard.yaml b/tests/components/emontx/test.esp8266-ard.yaml index 80a2cb2fc0..9ec9377437 100644 --- a/tests/components/emontx/test.esp8266-ard.yaml +++ b/tests/components/emontx/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/test.rp2040-ard.yaml b/tests/components/emontx/test.rp2040-ard.yaml index 410c579d4b..6f4952d8e5 100644 --- a/tests/components/emontx/test.rp2040-ard.yaml +++ b/tests/components/emontx/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml - -<<: !include common.yaml + emontx: !include common.yaml diff --git a/tests/components/emontx/validate.esp32-idf.yaml b/tests/components/emontx/validate.esp32-idf.yaml new file mode 100644 index 0000000000..7caee78a07 --- /dev/null +++ b/tests/components/emontx/validate.esp32-idf.yaml @@ -0,0 +1,73 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + emontx: !include common.yaml + +# Validate that each sensor type gets the correct default state_class, +# unit_of_measurement, device_class, and accuracy_decimals when NO overrides +# are provided. The values are intentionally omitted so apply_tag_defaults is +# exercised, not the user-override path. + +sensor: + # Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh, + # device_class=energy, accuracy_decimals=0 + - platform: emontx + tag_name: E1 + name: Energy 1 + emontx_id: test_emontx + + # Power sensor (P prefix): expects state_class=measurement, unit=W, + # device_class=power, accuracy_decimals=0 + - platform: emontx + tag_name: P1 + name: Power 1 + emontx_id: test_emontx + + # Voltage sensor (V prefix): expects state_class=measurement, unit=V, + # device_class=voltage, accuracy_decimals=2 + - platform: emontx + tag_name: V1 + name: Voltage 1 + emontx_id: test_emontx + + # Current sensor (I prefix): expects state_class=measurement, unit=A, + # device_class=current, accuracy_decimals=2 + - platform: emontx + tag_name: I1 + name: Current 1 + emontx_id: test_emontx + + # Temperature sensor (T prefix): expects state_class=measurement, unit=°C, + # device_class=temperature, accuracy_decimals=2 + - platform: emontx + tag_name: T1 + name: Temperature 1 + emontx_id: test_emontx + + # Pulse sensor (PULSE pattern): expects state_class=total_increasing, + # unit=pulses, device_class=energy, accuracy_decimals=0 + - platform: emontx + tag_name: PULSE1 + name: Pulse 1 + emontx_id: test_emontx + + # Power factor sensor (PF pattern): expects state_class=measurement, + # device_class=power_factor, accuracy_decimals=2 + - platform: emontx + tag_name: PF1 + name: Power Factor 1 + emontx_id: test_emontx + + # Unknown tag: no prefix match, falls back to state_class=measurement, + # accuracy_decimals=0 + - platform: emontx + tag_name: CUSTOM1 + name: Custom sensor + emontx_id: test_emontx + + # User override: verify that explicit values are respected and not clobbered + - platform: emontx + tag_name: E2 + name: Energy 2 (user override) + emontx_id: test_emontx + state_class: measurement + accuracy_decimals: 3 From 409d74a48da48ea3152c7d8aedb49f622123782f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:53:44 -0400 Subject: [PATCH 319/597] [esp32_hosted] Fire on_update_available trigger when update is detected (#18591) --- .../esp32_hosted/update/esp32_hosted_update.cpp | 9 +++++++++ .../esp32_hosted/test-embedded.esp32-p4-idf.yaml | 3 +++ .../components/esp32_hosted/test-http.esp32-p4-idf.yaml | 3 +++ 3 files changed, 15 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 351b0869b0..4eb5d1745b 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -135,6 +135,10 @@ void Esp32HostedUpdate::setup() { // Publish state this->status_clear_error(); this->publish_state(); + // Defer so the automation runs on the main loop after setup, not during App.setup() + if (this->state_ == update::UPDATE_STATE_AVAILABLE && this->update_available_trigger_) { + this->defer([this]() { this->update_available_trigger_->trigger(this->update_info_); }); + } #else // HTTP mode: check every 10s until network is ready (max 6 attempts) // Only if update interval is > 1 minute to avoid redundant checks @@ -185,6 +189,8 @@ void Esp32HostedUpdate::check() { return; } + const bool was_available = this->state_ == update::UPDATE_STATE_AVAILABLE; + // Compare versions if (this->update_info_.latest_version.empty() || this->update_info_.latest_version == this->update_info_.current_version) { @@ -197,6 +203,9 @@ void Esp32HostedUpdate::check() { this->update_info_.progress = 0.0f; this->status_clear_error(); this->publish_state(); + if (this->state_ == update::UPDATE_STATE_AVAILABLE && !was_available && this->update_available_trigger_) { + this->update_available_trigger_->trigger(this->update_info_); + } #endif } diff --git a/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml index 9640032b34..5cf33179ba 100644 --- a/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-embedded.esp32-p4-idf.yaml @@ -6,3 +6,6 @@ update: type: embedded path: $component_dir/test_firmware.bin sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31 + on_update_available: + then: + - logger.log: "Coprocessor update available" diff --git a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml index 17cde0f35d..88b620cfe8 100644 --- a/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-http.esp32-p4-idf.yaml @@ -8,3 +8,6 @@ update: type: http source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json update_interval: 6h + on_update_available: + then: + - logger.log: "Coprocessor update available" From aa944456e0ab4531d7b9184d5d97de166d522913 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:11:52 +1200 Subject: [PATCH 320/597] [core] Add type annotations to component Python (6/11) (#18343) --- esphome/components/ags10/sensor.py | 19 ++++++++++--- esphome/components/at581x/__init__.py | 19 ++++++++++--- esphome/components/at581x/switch/__init__.py | 3 ++- esphome/components/canbus/__init__.py | 20 +++++++++----- esphome/components/daly_bms/__init__.py | 3 ++- esphome/components/daly_bms/binary_sensor.py | 6 +++-- esphome/components/daly_bms/sensor.py | 6 +++-- esphome/components/daly_bms/text_sensor.py | 6 +++-- esphome/components/deep_sleep/__init__.py | 21 +++++++++++---- esphome/components/ds1307/time.py | 19 ++++++++++--- .../components/esp32_ble_tracker/__init__.py | 21 ++++++++++----- esphome/components/ethernet/__init__.py | 27 ++++++++++++------- esphome/components/hdc302x/sensor.py | 23 +++++++++++++--- esphome/components/htu21d/sensor.py | 19 ++++++++++--- esphome/components/ld6002b/__init__.py | 2 +- esphome/components/ld6002b/binary_sensor.py | 3 ++- esphome/components/ld6002b/button/__init__.py | 2 +- esphome/components/ld6002b/number/__init__.py | 2 +- esphome/components/ld6002b/select/__init__.py | 3 ++- esphome/components/ld6002b/sensor.py | 3 ++- esphome/components/ld6002b/switch/__init__.py | 3 ++- esphome/components/ld6002b/text_sensor.py | 3 ++- esphome/components/m5stack_8angle/__init__.py | 3 ++- .../m5stack_8angle/binary_sensor/__init__.py | 3 ++- .../m5stack_8angle/light/__init__.py | 3 ++- .../m5stack_8angle/sensor/__init__.py | 3 ++- esphome/components/modbus/__init__.py | 20 ++++++++------ esphome/components/openthread/__init__.py | 25 +++++++++++------ esphome/components/pulse_counter/sensor.py | 21 ++++++++++----- esphome/components/pulse_meter/sensor.py | 21 ++++++++++----- esphome/components/shelly_dimmer/light.py | 11 ++++---- 31 files changed, 246 insertions(+), 97 deletions(-) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 6491d7d810..8606e7c247 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_OHM, UNIT_PARTS_PER_BILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_RESISTANCE = "resistance" @@ -62,7 +65,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz") -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( AGS10_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def ags10newi2caddress_to_code(config, action_id, template_arg, args): +async def ags10newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) @@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( AGS10_SET_ZERO_POINT_SCHEMA, synchronous=True, ) -async def ags10setzeropoint_to_code(config, action_id, template_arg, args): +async def ags10setzeropoint_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) mode = await cg.templatable( diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 5031b72cce..193e62f615 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] DEPENDENCIES = ["i2c"] @@ -70,7 +73,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -91,7 +94,12 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio ), synchronous=True, ) -async def at581x_reset_to_code(config, action_id, template_arg, args): +async def at581x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -163,7 +171,12 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( RADAR_SETTINGS_SCHEMA, synchronous=True, ) -async def at581x_settings_to_code(config, action_id, template_arg, args): +async def at581x_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/at581x/switch/__init__.py b/esphome/components/at581x/switch/__init__.py index 8e1b82b356..7e45ed89ec 100644 --- a/esphome/components/at581x/switch/__init__.py +++ b/esphome/components/at581x/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI +from esphome.types import ConfigType from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: at581x_component = await cg.get_variable(config[CONF_AT581X_ID]) s = await switch.new_switch(config) await cg.register_parented(s, config[CONF_AT581X_ID]) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index fcd342ad38..b7de235dd1 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,10 +1,13 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mvturnho", "@danielschramm"] IS_PLATFORM_COMPONENT = True @@ -18,7 +21,7 @@ CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" -def validate_id(config): +def validate_id(config: ConfigType) -> ConfigType: if CONF_CAN_ID in config: can_id = config[CONF_CAN_ID] id_ext = config[CONF_USE_EXTENDED_ID] @@ -27,7 +30,7 @@ def validate_id(config): return config -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -71,7 +74,7 @@ CAN_SPEEDS = { } -def get_rate(value): +def get_rate(value: str) -> int: match = re.match(r"(\d+)(?:K(\d+)?)?BPS", value, re.IGNORECASE) if not match: raise ValueError(f"Invalid rate format: {value}") @@ -103,7 +106,7 @@ CANBUS_SCHEMA = cv.Schema( CANBUS_SCHEMA.add_extra(validate_id) -async def setup_canbus_core_(var, config): +async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_can_id([config[CONF_CAN_ID]])) cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) @@ -134,7 +137,7 @@ async def setup_canbus_core_(var, config): ) -async def register_canbus(var, config): +async def register_canbus(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) await setup_canbus_core_(var, config) @@ -157,7 +160,12 @@ async def register_canbus(var, config): ), synchronous=True, ) -async def canbus_action_to_code(config, action_id, template_arg, args): +async def canbus_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 87f00ce507..ba0be4d3a5 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] MULTI_CONF = True @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/daly_bms/binary_sensor.py b/esphome/components/daly_bms/binary_sensor.py index 95a2ae3b44..2b6ceffff1 100644 --- a/esphome/components/daly_bms/binary_sensor.py +++ b/esphome/components/daly_bms/binary_sensor.py @@ -1,6 +1,8 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -27,13 +29,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await binary_sensor.new_binary_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_binary_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/sensor.py b/esphome/components/daly_bms/sensor.py index aa92cfa86a..3e91fb280a 100644 --- a/esphome/components/daly_bms/sensor.py +++ b/esphome/components/daly_bms/sensor.py @@ -23,6 +23,8 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -222,13 +224,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/text_sensor.py b/esphome/components/daly_bms/text_sensor.py index 9f4e2df85a..1a91081bbf 100644 --- a/esphome/components/daly_bms/text_sensor.py +++ b/esphome/components/daly_bms/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATUS +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -23,13 +25,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 3b70f947d2..91131a3ed7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -38,7 +38,8 @@ from esphome.const import ( PLATFORM_NRF52, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType WAKEUP_PINS = { @@ -174,7 +175,7 @@ def validate_config(config: ConfigType) -> ConfigType: return config -def _validate_ex1_wakeup_mode(value): +def _validate_ex1_wakeup_mode(value: str) -> str: if value == "ALL_LOW": esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) if value == "ANY_LOW": @@ -345,7 +346,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -458,7 +459,12 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( DEEP_SLEEP_ENTER_SCHEMA, synchronous=True, ) -async def deep_sleep_enter_to_code(config, action_id, template_arg, args): +async def deep_sleep_enter_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: @@ -487,7 +493,12 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), synchronous=True, ) -async def deep_sleep_action_to_code(config, action_id, template_arg, args): +async def deep_sleep_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 0e7bb976a2..a3ae3eb5af 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def ds1307_write_time_to_code(config, action_id, template_arg, args): +async def ds1307_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def ds1307_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def ds1307_read_time_to_code(config, action_id, template_arg, args): +async def ds1307_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 28c8c7fcf1..4f6355df70 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -38,7 +38,8 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.enum import StrEnum from esphome.types import ConfigType @@ -262,7 +263,7 @@ ESP_BLE_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN) @@ -360,7 +361,7 @@ async def to_code(config): # chance to call register_ble_tracker and register_client before the list is checked # and added to the global defines list. @coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_features(): +async def _add_ble_features() -> None: # Add feature-specific defines based on what's needed required_features = _get_required_features() # Sensors registered through the neutral ble_device_base path (BLEHub) need @@ -389,8 +390,11 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def esp32_ble_tracker_start_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) @@ -414,8 +418,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( synchronous=True, ) async def esp32_ble_tracker_stop_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 7686b64cb4..cd5904f501 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -48,10 +48,12 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -276,7 +278,7 @@ def _validate_spi_interface(config: ConfigType) -> ConfigType: return config -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: if CONF_MANUAL_IP in config: use_address = str(config[CONF_MANUAL_IP][CONF_STATIC_IP]) @@ -441,7 +443,7 @@ GENERIC_SCHEMA = cv.All( ) -def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)): +def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) -> cv.All: return cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -517,7 +519,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate_spi(config): +def _final_validate_spi(config: ConfigType) -> None: if not CORE.is_esp32: return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: @@ -537,7 +539,7 @@ def _final_validate_spi(config): ) -def manual_ip(config): +def manual_ip(config: ConfigType) -> cg.StructInitializer: return cg.StructInitializer( ManualIP, ("static_ip", ip_address_literal(config[CONF_STATIC_IP])), @@ -548,7 +550,7 @@ def manual_ip(config): ) -def phy_register(address: int, value: int, page: int): +def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: return cg.StructInitializer( PHYRegister, ("address", address), @@ -558,7 +560,7 @@ def phy_register(address: int, value: int, page: int): @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) # Apply network priority before register_component (which emits the user's @@ -610,7 +612,7 @@ async def to_code(config): CORE.add_job(final_step) -async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, @@ -698,7 +700,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_component(name=component.name, ref=component.version) -async def _to_code_rp2040(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_rp2040(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) @@ -793,7 +795,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional Ethernet features.""" if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") @@ -845,7 +847,12 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -async def _new_pvariable_to_code(config, id_, template_arg, args): +async def _new_pvariable_to_code( + config: ConfigType, + id_: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(id_, template_arg) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index a6265b9b98..6d91c3df7c 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -16,6 +18,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -62,7 +67,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -86,7 +91,7 @@ HDC302X_HEATER_POWER_MAP = { } -def heater_power_value(value): +def heater_power_value(value: Any) -> cv.Lambda | int: """Accept enum names or raw uint16 values""" if isinstance(value, cv.Lambda): return value @@ -119,7 +124,12 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( HDC302X_HEATER_ON_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) @@ -135,7 +145,12 @@ async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): HDC302X_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 8808dc70f5..86dca77725 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -95,7 +98,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_heater_level_to_code(config, action_id, template_arg, args): +async def set_heater_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +123,12 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_heater_to_code(config, action_id, template_arg, args): +async def set_heater_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 99f2ead3bb..af1e501a6a 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -60,7 +60,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 63f7b40c23..74095d5ded 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from . import LD6002BComponent from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS @@ -36,7 +37,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if target_config := config.get(CONF_TARGET): diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index 508d5c2bc6..a664890a86 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -129,7 +129,7 @@ BUTTON_MAP = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: for key, button_type in BUTTON_MAP.items(): if button_config := config.get(key): b = cg.new_Pvariable(button_config[CONF_ID], button_type) diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 452e38d6e3..236b049f53 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -136,7 +136,7 @@ def final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, number_type, setter, min_value, max_value, step in ( diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py index 3da647ee2c..7f5e528b84 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED @@ -64,7 +65,7 @@ SELECT_MAP = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, select_type, setter, options in SELECT_MAP: diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 3aedaf9fdd..cceefb3837 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType from . import LD6002BComponent from .const import ( @@ -150,7 +151,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py index d27baa87fe..a414308b65 100644 --- a/esphome/components/ld6002b/switch/__init__.py +++ b/esphome/components/ld6002b/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import LD6002BComponent, ld6002b_ns from ..const import ( @@ -46,7 +47,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) for key, switch_type, setter in ( diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py index a18d387437..0e8e2e80e7 100644 --- a/esphome/components/ld6002b/text_sensor.py +++ b/esphome/components/ld6002b/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType from . import LD6002BComponent from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) if work_mode_config := config.get(CONF_WORK_MODE): sens = await text_sensor.new_text_sensor(work_mode_config) diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index a1c197b381..6404bcf64c 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@rnauber"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(i2c.i2c_device_schema(0x43)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/m5stack_8angle/binary_sensor/__init__.py b/esphome/components/m5stack_8angle/binary_sensor/__init__.py index 22ab73e901..09398876d4 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/__init__.py +++ b/esphome/components/m5stack_8angle/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) sens = await binary_sensor.new_binary_sensor(config) cg.add(sens.set_parent(hub)) diff --git a/esphome/components/m5stack_8angle/light/__init__.py b/esphome/components/m5stack_8angle/light/__init__.py index 806ecaabf4..5c4863acf7 100644 --- a/esphome/components/m5stack_8angle/light/__init__.py +++ b/esphome/components/m5stack_8angle/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) lights = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(lights, config) diff --git a/esphome/components/m5stack_8angle/sensor/__init__.py b/esphome/components/m5stack_8angle/sensor/__init__.py index 2132eaa4c2..87d1425241 100644 --- a/esphome/components/m5stack_8angle/sensor/__init__.py +++ b/esphome/components/m5stack_8angle/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import ( CONF_M5STACK_8ANGLE_ID, @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_M5STACK_8ANGLE_ID]) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 58bd0f65dc..a98591c6bc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,8 +8,10 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -84,7 +86,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(modbus_ns.using) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -112,7 +114,9 @@ def _validate_server_address(value: Any) -> int: return address -def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): +def modbus_device_schema( + default_address: int | None, role: Literal["client", "server"] = "client" +) -> cv.Schema: hub_type = ModbusClient if role == "client" else ModbusServer address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t schema = { @@ -127,14 +131,14 @@ def modbus_device_schema(default_address, role: Literal["client", "server"] = "c def final_validate_modbus_device( name: str, *, role: Literal["server", "client"] | None = None -): - def validate_role(value): +) -> cv.Schema: + def validate_role(value: str) -> str: assert role in MODBUS_ROLES if value != role: raise cv.Invalid(f"Component {name} requires role to be {role}") return value - def validate_hub(hub_config): + def validate_hub(hub_config: ConfigType) -> ConfigType: hub_schema = {} if role is not None: hub_schema[cv.Required(CONF_ROLE)] = validate_role @@ -147,19 +151,19 @@ def final_validate_modbus_device( ) -async def register_modbus_client_device(var, config): +async def register_modbus_client_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) -async def register_modbus_server_device(var, config): +async def register_modbus_server_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) -async def register_modbus_device(var, config): +async def register_modbus_device(var: MockObj, config: ConfigType) -> None: # Remove before 2026.12.0 _LOGGER.warning( "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 4018ad81e7..ab69f5d9ae 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( @@ -31,10 +33,12 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -76,7 +80,7 @@ CONF_DEVICE_TYPES = [ ] -def _validate_txpower(value): +def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: variant = get_esp32_variant() @@ -90,7 +94,7 @@ def _validate_txpower(value): return value # Unsupported, fail later with clear error -def set_sdkconfig_options(config): +def set_sdkconfig_options(config: ConfigType) -> None: # and expose options for using SPI/UART RCPs add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) @@ -180,7 +184,7 @@ def _validate(config: ConfigType) -> ConfigType: return config -def _require_vfs_select(config): +def _require_vfs_select(config: ConfigType) -> ConfigType: """Register VFS select requirement during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: @@ -188,7 +192,7 @@ def _require_vfs_select(config): return config -def _validate_platform(config): +def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config return only_on_variant( @@ -203,7 +207,7 @@ def _validate_platform(config): )(config) -def _validate_tlv_hex(value): +def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: raise cv.Invalid("TLV must have an even number of hex characters") @@ -242,7 +246,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: full_config = fv.full_config.get() network_config = full_config.get("network", {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -274,7 +278,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -339,7 +343,12 @@ POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( POLL_PERIOD_ACTION_SCHEMA, synchronous=True, ) -async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): +async def openthread_poll_period_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 3326745846..7c5a0590d7 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor @@ -19,7 +21,9 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_USE_PCNT = "use_pcnt" @@ -42,7 +46,7 @@ SetTotalPulsesAction = pulse_counter_ns.class_( ) -def validate_internal_filter(value): +def validate_internal_filter(value: ConfigType) -> ConfigType: use_pcnt = value.get(CONF_USE_PCNT) if CORE.is_esp8266 and use_pcnt: raise cv.Invalid( @@ -63,7 +67,7 @@ def validate_internal_filter(value): return value -def validate_pulse_counter_pin(value): +def validate_pulse_counter_pin(value: Any) -> ConfigType: value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: raise cv.Invalid( @@ -72,7 +76,7 @@ def validate_pulse_counter_pin(value): return value -def validate_count_mode(value): +def validate_count_mode(value: ConfigType) -> ConfigType: rising_edge = value[CONF_RISING_EDGE] falling_edge = value[CONF_FALLING_EDGE] if rising_edge == "DISABLE" and falling_edge == "DISABLE": @@ -126,7 +130,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: use_pcnt = config.get(CONF_USE_PCNT) if CORE.is_esp32 and use_pcnt: include_builtin_idf_component("esp_driver_pcnt") @@ -157,7 +161,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_total_action_to_code(config, action_id, template_arg, args): +async def set_total_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index ab3dd2a249..9bda891efc 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor @@ -17,7 +19,9 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE +from esphome.core import CORE, ID, TimePeriodMicroseconds +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@stevebaxter", "@cstaahl", "@TrentHouliston"] @@ -37,18 +41,18 @@ FILTER_MODES = { SetTotalPulsesAction = pulse_meter_ns.class_("SetTotalPulsesAction", automation.Action) -def validate_internal_filter(value): +def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: return cv.positive_time_period_microseconds(value) -def validate_timeout(value): +def validate_timeout(value: Any) -> TimePeriodMicroseconds: value = cv.positive_time_period_microseconds(value) if value.total_minutes > 70: raise cv.Invalid("Maximum timeout is 70 minutes") return value -def validate_pulse_meter_pin(value): +def validate_pulse_meter_pin(value: Any) -> ConfigType: value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: raise cv.Invalid( @@ -81,7 +85,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -107,7 +111,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_total_action_to_code(config, action_id, template_arg, args): +async def set_total_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index dd99fcbc90..c166076e0f 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -1,6 +1,7 @@ import hashlib from pathlib import Path import re +from typing import Any from esphome import external_files, pins import esphome.codegen as cg @@ -66,7 +67,7 @@ KNOWN_FIRMWARE = { } -def parse_firmware_version(value): +def parse_firmware_version(value: str) -> tuple[int, int]: match = re.fullmatch(r"(\d+)\.(\d+)", value) if match is None: raise ValueError(f"Not a valid version number {value}") @@ -154,7 +155,7 @@ def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) -def validate_firmware(value): +def validate_firmware(value: ConfigType) -> ConfigType: config = value.copy() if CONF_URL not in config: try: @@ -167,14 +168,14 @@ def validate_firmware(value): return config -def validate_sha256(value): +def validate_sha256(value: Any) -> str: value = cv.string(value) if not re.fullmatch(r"[0-9a-fA-F]{64}", value): raise ValueError(f"Not a valid SHA256 hex string: {value}") return value -def validate_version(value): +def validate_version(value: str) -> str: parse_firmware_version(value) return value @@ -231,7 +232,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: fw_hex = get_firmware(config[CONF_FIRMWARE]) fw_major, fw_minor = parse_firmware_version(config[CONF_FIRMWARE][CONF_VERSION]) From 00cffa09a2491be8a39ffd1a62d2c6355bedc61c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 21 Aug 2026 14:05:07 -0400 Subject: [PATCH 321/597] [sendspin] Convert tests to package-style includes (#18588) --- tests/components/sendspin/common-action.yaml | 2 +- tests/components/sendspin/common-ethernet.yaml | 5 +++++ tests/components/sendspin/common-hub.yaml | 6 ++++++ tests/components/sendspin/common-media_player.yaml | 3 ++- tests/components/sendspin/common-media_source.yaml | 3 ++- tests/components/sendspin/common-sensor.yaml | 3 ++- tests/components/sendspin/common-text_sensor.yaml | 3 ++- tests/components/sendspin/common.yaml | 10 +++------- tests/components/sendspin/test-action.esp32-idf.yaml | 3 ++- .../components/sendspin/test-ethernet.esp32-idf.yaml | 11 ++--------- .../sendspin/test-media_player.esp32-idf.yaml | 3 ++- .../sendspin/test-media_source.esp32-idf.yaml | 3 ++- tests/components/sendspin/test-sensor.esp32-idf.yaml | 3 ++- .../sendspin/test-text_sensor.esp32-idf.yaml | 3 ++- tests/components/sendspin/test.esp32-idf.yaml | 3 ++- 15 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 tests/components/sendspin/common-ethernet.yaml create mode 100644 tests/components/sendspin/common-hub.yaml diff --git a/tests/components/sendspin/common-action.yaml b/tests/components/sendspin/common-action.yaml index 16f19ad7d1..1bba06ab46 100644 --- a/tests/components/sendspin/common-action.yaml +++ b/tests/components/sendspin/common-action.yaml @@ -1,6 +1,6 @@ # `sendspin.switch` action enables the controller role, so we use a standalone test packages: - base: !include common.yaml + sendspin: !include common.yaml wifi: on_connect: diff --git a/tests/components/sendspin/common-ethernet.yaml b/tests/components/sendspin/common-ethernet.yaml new file mode 100644 index 0000000000..276163cda1 --- /dev/null +++ b/tests/components/sendspin/common-ethernet.yaml @@ -0,0 +1,5 @@ +packages: + sendspin_hub: !include common-hub.yaml + +ethernet: + type: OPENETH diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml new file mode 100644 index 0000000000..7a6a9ffd4f --- /dev/null +++ b/tests/components/sendspin/common-hub.yaml @@ -0,0 +1,6 @@ +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true diff --git a/tests/components/sendspin/common-media_player.yaml b/tests/components/sendspin/common-media_player.yaml index d3792cf470..afb8b992f3 100644 --- a/tests/components/sendspin/common-media_player.yaml +++ b/tests/components/sendspin/common-media_player.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml media_player: - platform: sendspin diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 5b33a54647..1977b79c04 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml media_source: - platform: sendspin diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml index 6d9745cff9..6467e38b90 100644 --- a/tests/components/sendspin/common-sensor.yaml +++ b/tests/components/sendspin/common-sensor.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml sensor: - platform: sendspin diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index fc6a56a21a..23111e8d37 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml text_sensor: - platform: sendspin diff --git a/tests/components/sendspin/common.yaml b/tests/components/sendspin/common.yaml index 9d7da76758..980635b4e3 100644 --- a/tests/components/sendspin/common.yaml +++ b/tests/components/sendspin/common.yaml @@ -1,9 +1,5 @@ +packages: + sendspin_hub: !include common-hub.yaml + wifi: ap: - -psram: - mode: quad - -sendspin: - id: sendspin_hub_id - task_stack_in_psram: true diff --git a/tests/components/sendspin/test-action.esp32-idf.yaml b/tests/components/sendspin/test-action.esp32-idf.yaml index 70a7ee1bad..080eb59034 100644 --- a/tests/components/sendspin/test-action.esp32-idf.yaml +++ b/tests/components/sendspin/test-action.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-action.yaml +packages: + sendspin: !include common-action.yaml diff --git a/tests/components/sendspin/test-ethernet.esp32-idf.yaml b/tests/components/sendspin/test-ethernet.esp32-idf.yaml index 069e397d99..09a951d211 100644 --- a/tests/components/sendspin/test-ethernet.esp32-idf.yaml +++ b/tests/components/sendspin/test-ethernet.esp32-idf.yaml @@ -1,9 +1,2 @@ -ethernet: - type: OPENETH - -psram: - mode: quad - -sendspin: - id: sendspin_hub_id - task_stack_in_psram: true +packages: + sendspin: !include common-ethernet.yaml diff --git a/tests/components/sendspin/test-media_player.esp32-idf.yaml b/tests/components/sendspin/test-media_player.esp32-idf.yaml index cbbdb07c77..bcd4062bbe 100644 --- a/tests/components/sendspin/test-media_player.esp32-idf.yaml +++ b/tests/components/sendspin/test-media_player.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-media_player.yaml +packages: + sendspin: !include common-media_player.yaml diff --git a/tests/components/sendspin/test-media_source.esp32-idf.yaml b/tests/components/sendspin/test-media_source.esp32-idf.yaml index 47aeb2257c..faadccb06d 100644 --- a/tests/components/sendspin/test-media_source.esp32-idf.yaml +++ b/tests/components/sendspin/test-media_source.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-media_source.yaml +packages: + sendspin: !include common-media_source.yaml diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml index f9127d47bc..1646902ca3 100644 --- a/tests/components/sendspin/test-sensor.esp32-idf.yaml +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-sensor.yaml +packages: + sendspin: !include common-sensor.yaml diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml index 8998b8896e..69cf8e63fb 100644 --- a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common-text_sensor.yaml +packages: + sendspin: !include common-text_sensor.yaml diff --git a/tests/components/sendspin/test.esp32-idf.yaml b/tests/components/sendspin/test.esp32-idf.yaml index dade44d145..36667f7fae 100644 --- a/tests/components/sendspin/test.esp32-idf.yaml +++ b/tests/components/sendspin/test.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + sendspin: !include common.yaml From abc9098bd833ca2186b4dc0ec59bf32d049d862d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:05:00 -0500 Subject: [PATCH 322/597] Bump bundled esphome-device-builder to 1.12.3 (#18601) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2bbe5331e5..4cde6505b3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.3 RUN \ platformio settings set enable_telemetry No \ From 11ea819bc7728d72586f34f381de3c57d1584ff5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:36:47 -0500 Subject: [PATCH 323/597] Bump aioesphomeapi from 45.12.0 to 45.13.1 (#18600) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 740a8c1a79..3362e43239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.12.0 +aioesphomeapi==45.13.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 8e9fb0f93c9c8da438dd1f301e8ef593d94ca4c2 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 21 Aug 2026 23:10:47 -0500 Subject: [PATCH 324/597] [remote_transmitter] Fix repeat gap timing on LibreTiny Beken (#18585) Co-authored-by: J. Nick Koston --- .../remote_transmitter/remote_transmitter.cpp | 40 ++++++++++++------- .../remote_transmitter/remote_transmitter.h | 2 +- .../remote_transmitter/test.bk72xx-ard.yaml | 7 ++++ 3 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 tests/components/remote_transmitter/test.bk72xx-ard.yaml diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 49c711330b..31e7464314 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -81,25 +81,37 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); - this->target_time_ = 0; this->transmit_trigger_.trigger(); for (uint32_t i = 0; i < send_times; i++) { - InterruptLock lock; - for (int32_t item : this->temp_.get_data()) { - if (item > 0) { - const auto length = uint32_t(item); - this->mark_(on_time, off_time, length); - } else { - const auto length = uint32_t(-item); - this->space_(length); + { + InterruptLock lock; + // Re-anchor every iteration: timing must never span a lock boundary, as micros() can + // jump when interrupts are re-enabled between repeats (e.g. LibreTiny's Beken micros() + // discards its interrupt-lock correction, stretching the repeat gap by the lock duration) + this->target_time_ = 0; + for (int32_t item : this->temp_.get_data()) { + if (item > 0) { + const auto length = uint32_t(item); + this->mark_(on_time, off_time, length); + } else { + const auto length = uint32_t(-item); + this->space_(length); + } + App.feed_wdt(); } - App.feed_wdt(); + this->await_target_time_(); // wait for duration of last pulse + this->pin_->digital_write(false); } - this->await_target_time_(); // wait for duration of last pulse - this->pin_->digital_write(false); - if (i + 1 < send_times) - this->target_time_ += send_wait; + if (i + 1 < send_times) { + // Wait out the repeat gap with interrupts enabled: wait_time is unbounded user config + // (previously this spin ran inside the next iteration's lock, disabling interrupts for + // the whole gap). Anchoring after the lock release keeps it exact on all platforms. + const uint32_t gap_end = micros() + send_wait; + while ((int32_t) (gap_end - micros()) > 0) { + App.feed_wdt(); + } + } } this->complete_trigger_.trigger(); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index e2d33d13cc..0aa04682ba 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -72,7 +72,7 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa void space_(uint32_t usec); void await_target_time_(); - uint32_t target_time_; + uint32_t target_time_{0}; #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2a5cceddec --- /dev/null +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO26 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From 5a300e92f14ef6e2f308dd2394bc5f999fcd5b5f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:14:56 -0500 Subject: [PATCH 325/597] [wifi] Inline the trivial WiFiScanResult accessors (#18613) --- esphome/components/wifi/wifi_component.cpp | 8 -------- esphome/components/wifi/wifi_component.h | 14 +++++++------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 127eb50df1..5ed5fc9094 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2396,14 +2396,6 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { } return true; } -bool WiFiScanResult::get_matches() const { return this->matches_; } -void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; } -const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; } -uint8_t WiFiScanResult::get_channel() const { return this->channel_; } -int8_t WiFiScanResult::get_rssi() const { return this->rssi_; } -bool WiFiScanResult::get_with_auth() const { return this->with_auth_; } -bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; } - bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; } void WiFiComponent::clear_roaming_state_() { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ea043fd5c6..ff90fbe49b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -319,14 +319,14 @@ class WiFiScanResult { bool matches(const WiFiAP &config) const; - bool get_matches() const; - void set_matches(bool matches); - const bssid_t &get_bssid() const; + bool get_matches() const { return this->matches_; } + void set_matches(bool matches) { this->matches_ = matches; } + const bssid_t &get_bssid() const { return this->bssid_; } StringRef get_ssid() const { return this->ssid_.ref(); } - uint8_t get_channel() const; - int8_t get_rssi() const; - bool get_with_auth() const; - bool get_is_hidden() const; + uint8_t get_channel() const { return this->channel_; } + int8_t get_rssi() const { return this->rssi_; } + bool get_with_auth() const { return this->with_auth_; } + bool get_is_hidden() const { return this->is_hidden_; } int8_t get_priority() const { return priority_; } void set_priority(int8_t priority) { priority_ = priority; } From a30e82459f2d7fbb97d2c4861f87b2c784938c9f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:51:19 -0500 Subject: [PATCH 326/597] [deep_sleep] Reject wakeup_pin_mode at both levels on BK72xx (#18615) --- esphome/components/deep_sleep/__init__.py | 5 +++ .../deep_sleep/test_deep_sleep.py | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 91131a3ed7..dc03708645 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -163,6 +163,11 @@ def validate_config(config: ConfigType) -> ConfigType: "You need to remove the global wakeup_pin_mode and define it per pin" ) if wakeup_pins: + if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: + raise cv.Invalid( + "Specify wakeup_pin_mode either at the top level under deep_sleep " + "or under the pin entry, not both" + ) wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) elif ( isinstance(config.get(CONF_WAKEUP_PIN), list) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index f105ed5888..e68b1d17cc 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,13 @@ """Tests for the deep sleep component.""" +import pytest + +from esphome import config_validation as cv +from esphome.components import deep_sleep +from esphome.const import CONF_WAKEUP_PIN, PlatformFramework + +from ..types import SetCoreConfigCallable + def test_deep_sleep_setup(generate_main): """ @@ -83,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main): " .gpio_cause = 30000,\n" "});" ) in main_cpp + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + result = deep_sleep.validate_config(config) + + assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result + assert ( + result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" + ) From 65704e881f868546390770ec1ca75b63c97739de Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sat, 22 Aug 2026 06:52:18 +0200 Subject: [PATCH 327/597] [mitsubishi_cn105] Add Fahrenheit support (#15488) Co-authored-by: J. Nick Koston --- .../components/mitsubishi_cn105/__init__.py | 3 + .../mitsubishi_cn105/mitsubishi_cn105.h | 1 + .../mitsubishi_cn105_climate.cpp | 20 ++++--- .../mitsubishi_cn105_component.cpp | 7 +++ .../mitsubishi_cn105_component.h | 35 ++++++++++- esphome/components/mqtt/mqtt_climate.cpp | 3 +- .../mitsubishi_cn105_climate_tests.cpp | 60 +++++++++++++++++++ tests/components/mitsubishi_cn105/common.h | 1 + tests/components/mitsubishi_cn105/common.yaml | 1 + 9 files changed, 121 insertions(+), 10 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 450d1cd222..470b7be5fc 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_ON_STATE, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL, + CONF_USE_FAHRENHEIT, ) from esphome.core import ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj @@ -71,6 +72,7 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, cv.Optional(CONF_VANE): cv.Schema( { cv.Optional(CONF_ON_STATE): automation.validate_automation({}), @@ -114,6 +116,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] ) ) + cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): cg.add_global(mitsubishi_ns.using) for conf in on_state: diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index b6b11b4820..4d3f899dee 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -83,6 +83,7 @@ class MitsubishiCN105 { return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) : !std::isnan(this->status_.target_temperature); } + bool is_temperature_encoding_b() const { return this->property_context_.use_temperature_encoding_b; } void set_power(bool power_on); void set_target_temperature(float target_temperature); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 197e1e1bb5..17ff6d34ca 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -50,7 +50,11 @@ static constexpr std::optional reverse_map_lookup(const std::arrayparent_->get_temperature_mapping().get_use_fahrenheit() ? 'F' : 'C'); +} void MitsubishiCN105Climate::setup() { this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); @@ -72,13 +76,15 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.set_supported_swing_modes(this->supported_swing_modes_); - traits.set_visual_min_temperature(16.0f); - traits.set_visual_max_temperature(31.0f); + const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit(); + traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS); + traits.set_visual_min_temperature(use_fahrenheit ? 61.0f : 16.0f); + traits.set_visual_max_temperature(use_fahrenheit ? 88.0f : 31.0f); traits.set_visual_temperature_step(1.0f); if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - traits.set_visual_current_temperature_step(0.5f); + traits.set_visual_current_temperature_step(use_fahrenheit ? 1.0f : 0.5f); } return traits; @@ -86,7 +92,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->parent_->set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(this->parent_->get_temperature_mapping().to_mitsubishi(*target_temperature)); } if (const auto mode = call.get_mode()) { @@ -139,10 +145,10 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { void MitsubishiCN105Climate::apply_values_() { const auto &status = this->parent_->status(); - this->target_temperature = status.target_temperature; + this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature); if (this->parent_->is_telemetry_polling_enabled()) { - this->current_temperature = status.room_temperature; + this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature); } if (status.power_on) { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 8e9e954645..e2a6ee05af 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -27,6 +27,13 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); } void MitsubishiCN105Component::loop() { if (this->hp_.update()) { + // Encoding A only supports whole °C values and cannot represent native °F setpoints accurately. + // See https://github.com/esphome/esphome/pull/15488#issuecomment-5268304343 + if (this->temperature_mapping_.get_use_fahrenheit() && !this->hp_.is_temperature_encoding_b()) { + ESP_LOGE(TAG, "Unit reports encoding A, which cannot accurately convert °F setpoints; disable 'use_fahrenheit'"); + this->mark_failed(); + return; + } this->notify_status_listeners_(); } } diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 6461fb464b..508a15e6d5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -3,13 +3,43 @@ #include "mitsubishi_cn105.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" -#include +#include +#include #include +#include namespace esphome::mitsubishi_cn105 { +struct TemperatureMapping { + float to_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + const int fahrenheit = std::clamp(static_cast(std::round(value)), 61, 88); + return 0.5f * (fahrenheit - 28 + (fahrenheit > 68) - (fahrenheit < 68)); + } + + float from_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + if (value < 16.0f || value > 30.5f) { + return celsius_to_fahrenheit(value); + } + const int mitsubishi_half_degrees = static_cast(std::round(value * 2.0f)); + return mitsubishi_half_degrees + 29 - (mitsubishi_half_degrees >= 40) - (mitsubishi_half_degrees > 40); + } + + bool get_use_fahrenheit() const { return this->use_fahrenheit_; } + void set_use_fahrenheit(bool value) { this->use_fahrenheit_ = value; } + + protected: + bool use_fahrenheit_{false}; +}; + enum VerticalVaneMode : uint8_t { VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), @@ -60,6 +90,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + void set_use_fahrenheit(bool value) { this->temperature_mapping_.set_use_fahrenheit(value); } void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } @@ -75,6 +106,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + const TemperatureMapping &get_temperature_mapping() const { return this->temperature_mapping_; } template void add_on_status_callback(F &&callback) { this->status_callback_.add(std::forward(callback)); @@ -99,6 +131,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { } MitsubishiCN105 hp_; + TemperatureMapping temperature_mapping_; CallbackManager status_callback_; LazyCallbackManager vane_state_callback_; }; diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index d5ee4c6a9b..0e6a374f9b 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -118,8 +118,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; - // temperature units are always coerced to Celsius internally - root[MQTT_TEMPERATURE_UNIT] = "C"; + root[MQTT_TEMPERATURE_UNIT] = traits.get_temperature_unit() == TemperatureUnit::FAHRENHEIT ? "F" : "C"; // min_humidity root[MQTT_MIN_HUMIDITY] = traits.get_visual_min_humidity(); diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp index 36e0fc90b4..b91252c9fa 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -1,7 +1,67 @@ +#include +#include #include "../common.h" namespace esphome::mitsubishi_cn105::testing { +TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) { + TestableMitsubishiCN105Climate sut; + const auto mapping = TemperatureMapping(); + + for (int temperature = 16; temperature <= 31; ++temperature) { + EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature); + EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature); + } + + const auto traits = sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f); +} + +TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) { + TestableMitsubishiCN105Climate sut; + auto mapping = TemperatureMapping(); + mapping.set_use_fahrenheit(true); + sut.set_use_fahrenheit(true); + + const std::array cases{ + std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f}, + std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f}, + std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f}, + std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f}, + std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f}, + std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f}, + }; + + for (const auto &[fahrenheit, mitsubishi_celsius] : cases) { + EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius); + EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit); + } + const auto traits = sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f); +} + +TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) { + auto mapping = TemperatureMapping(); + mapping.set_use_fahrenheit(true); + + const std::array cases{ + std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f}, + std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f}, + }; + + for (const auto &[celsius, fahrenheit] : cases) { + EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit); + } +} + TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { TestableMitsubishiCN105Climate sut; diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index f542880eef..ee287d2548 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -73,6 +73,7 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; MitsubishiCN105::Status &status() { return const_cast(this->component_.status()); } + void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); } protected: MitsubishiCN105Component component_; diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index fc14724786..3f7e8c8f95 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,7 @@ mitsubishi_cn105: uart_id: uart_bus update_interval: 30s telemetry_request_min_interval: 120s + use_fahrenheit: true vane: on_state: - logger.log: From dccf55eadc6c41eaadeca24d10d7cd470ccf8bd7 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 21 Aug 2026 23:53:16 -0500 Subject: [PATCH 328/597] [remote_transmitter] Use hardware PWM on rtl87xx to fix watchdog crash (#18579) --- .../components/remote_transmitter/__init__.py | 4 +- .../remote_transmitter/remote_transmitter.cpp | 3 +- .../remote_transmitter/remote_transmitter.h | 13 +- .../remote_transmitter_rtl87xx.cpp | 137 ++++++++++++++++++ .../remote_transmitter/test.rtl87xx-ard.yaml | 7 + 5 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp create mode 100644 tests/components/remote_transmitter/test.rtl87xx-ard.yaml diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index a97b925e06..9d8761ea90 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,12 +185,14 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, + "remote_transmitter_rtl87xx.cpp": { + PlatformFramework.RTL87XX_ARDUINO, + }, "remote_transmitter.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, PlatformFramework.RP2_ARDUINO, }, diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 31e7464314..67341e936f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \ + (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 0aa04682ba..94bcb74b09 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -65,14 +65,21 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) + void await_target_time_(); + uint32_t target_time_{0}; +#endif +#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \ + (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); void space_(uint32_t usec); - - void await_target_time_(); - uint32_t target_time_{0}; +#endif +#ifdef USE_RTL87XX + // Carrier frequency the PWM is currently configured for; 0 = not yet configured + uint32_t current_carrier_frequency_{0}; + void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp new file mode 100644 index 0000000000..b7078b9d69 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -0,0 +1,137 @@ +#include "remote_transmitter.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +// clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h +#if defined(USE_RTL87XX) && !defined(CLANG_TIDY) + +// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for +// type-name collisions between the two (e.g. PinMode) +#include +#include +#include + +namespace esphome::remote_transmitter { + +static const char *const TAG = "remote_transmitter"; + +// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier +// generation requires disabling interrupts for the whole frame, but this core's micros() is derived +// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and +// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and +// interrupts can stay enabled. +// +// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer: +// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which +// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the +// heap. pwmout_period_us() changes the frequency with no mode transitions. + +void RemoteTransmitterComponent::setup() { + // Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin + // management, and the pad is then never handed over to the PWM peripheral -- pwmout_init() + // must own the pin from the start. + PinInfo *info = pinInfo(this->pin_->get_pin()); + if (info == nullptr || !pinSupported(info, PIN_PWM)) { + // checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure + ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin()); + this->mark_failed(); + return; + } + auto *pwm = new pwmout_t(); + this->pwm_ = pwm; + pwmout_init(pwm, static_cast(info->gpio)); +#if LT_RTL8720C + // only the AmebaZ2 SDK's pwmout_s reports init success + if (!pwm->is_init) { + ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin()); + delete pwm; + this->pwm_ = nullptr; + this->mark_failed(); + return; + } +#endif + pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission + pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f); +} + +void RemoteTransmitterComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Remote Transmitter:\n" + " Carrier Duty: %u%%", + this->carrier_duty_percent_); + LOG_PIN(" Pin: ", this->pin_); +} + +void RemoteTransmitterComponent::await_target_time_() { + const uint32_t current_time = micros(); + if (this->target_time_ == 0) { + this->target_time_ = current_time; + } else { + while ((int32_t) (this->target_time_ - micros()) > 0) { + } + } +} + +void RemoteTransmitterComponent::digital_write(bool value) { + if (this->pwm_ == nullptr) + return; + pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); +} + +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + auto *pwm = static_cast(this->pwm_); + if (pwm == nullptr) { + ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + return; + } + ESP_LOGD(TAG, "Sending remote code"); + const uint32_t carrier_frequency = this->temp_.get_carrier_frequency(); + // unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks + float mark_duty = + (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f; + float space_duty = 0.0f; + if (this->pin_->is_inverted()) { + mark_duty = 1.0f - mark_duty; + space_duty = 1.0f; + } + if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) { + // round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period + const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); + pwmout_period_us(pwm, period); + this->current_carrier_frequency_ = carrier_frequency; + } + this->transmit_trigger_.trigger(); + const UBaseType_t saved_priority = uxTaskPriorityGet(nullptr); + for (uint32_t i = 0; i < send_times; i++) { + // Boost task priority for the frame only, so WiFi/lwIP tasks can't preempt mid-frame and + // merge adjacent marks. Interrupts stay enabled: micros() needs the FreeRTOS tick, and + // ISR latency is within receiver tolerance. + vTaskPrioritySet(nullptr, configMAX_PRIORITIES - 1); + // Re-anchor every iteration: a late exit from the normal-priority gap wait must not + // leave the schedule behind micros(), which would compress the next frame's leading items + this->target_time_ = 0; + for (int32_t item : this->temp_.get_data()) { + const bool is_mark = item > 0; + this->await_target_time_(); + pwmout_write(pwm, is_mark ? mark_duty : space_duty); + this->target_time_ += is_mark ? uint32_t(item) : uint32_t(-item); + App.feed_wdt(); + } + this->await_target_time_(); // wait for duration of last pulse + pwmout_write(pwm, space_duty); + vTaskPrioritySet(nullptr, saved_priority); + if (i + 1 < send_times) { + // The repeat gap is user-configurable and unbounded, so wait it out at normal + // priority, feeding the watchdog + const uint32_t gap_end = micros() + send_wait; + while ((int32_t) (gap_end - micros()) > 0) { + App.feed_wdt(); + } + } + } + this->complete_trigger_.trigger(); +} + +} // namespace esphome::remote_transmitter + +#endif // USE_RTL87XX && !CLANG_TIDY diff --git a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..769adbdf5c --- /dev/null +++ b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO12 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From ef1d77885dd5a7f1beef4e3d34e22e26d5661fa1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:04:08 -0500 Subject: [PATCH 329/597] [captive_portal] Show each network once in the scan list (#17847) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Bluetooth Devices Bot --- .../captive_portal/captive_portal.cpp | 11 +- esphome/components/captive_portal/scan_list.h | 28 ++++ esphome/components/wifi/wifi_component.h | 1 + tests/components/captive_portal/__init__.py | 10 ++ .../captive_portal/scan_list_test.cpp | 130 ++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 esphome/components/captive_portal/scan_list.h create mode 100644 tests/components/captive_portal/__init__.py create mode 100644 tests/components/captive_portal/scan_list_test.cpp diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 704a61d4de..ffd121499b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,6 +6,7 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/wifi_component.h" #include "captive_index.h" +#include "scan_list.h" namespace esphome::captive_portal { @@ -33,8 +34,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { // Invariant: only bounded in-memory work under the lock; the network send // happens later in request->send() wifi::ScanResultsLock lock(wifi::global_wifi_component); - for (const auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) + const auto &results = wifi::global_wifi_component->get_scan_result(); + for (const auto &scan : results) { + bool with_auth = false; + if (!should_show_scan_entry(results, scan, with_auth)) continue; json_escape_into_buffer(escaped_ssid, scan.get_ssid()); @@ -44,10 +47,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->print(ESPHOME_F("\",\"rssi\":")); stream->print(scan.get_rssi()); stream->print(ESPHOME_F(",\"lock\":")); - stream->print(scan.get_with_auth()); + stream->print(with_auth); stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), with_auth); #endif } } diff --git a/esphome/components/captive_portal/scan_list.h b/esphome/components/captive_portal/scan_list.h new file mode 100644 index 0000000000..d24a88a670 --- /dev/null +++ b/esphome/components/captive_portal/scan_list.h @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace esphome::captive_portal { + +// A scan lists every BSSID, so one SSID can appear several times. Returns true for +// the strongest entry per SSID (earliest on ties), never for hidden entries. scan +// must be an element of results. with_auth is written only when returning true and +// is set if any entry with that SSID needs a key. Templated for host tests. +template +bool should_show_scan_entry(const Results &results, const Entry &scan, bool &with_auth) { + if (scan.get_is_hidden()) + return false; + const int8_t rssi = scan.get_rssi(); + bool any_auth = false; + for (const auto &other : results) { + if (other.get_is_hidden() || !other.ssid_equals(scan)) + continue; + // Same array, so address order is index order. scan fails both checks against itself. + if (other.get_rssi() > rssi || (other.get_rssi() == rssi && &other < &scan)) + return false; + any_auth |= other.get_with_auth(); + } + with_auth = any_auth; + return true; +} + +} // namespace esphome::captive_portal diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ff90fbe49b..c54fbc004b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -327,6 +327,7 @@ class WiFiScanResult { int8_t get_rssi() const { return this->rssi_; } bool get_with_auth() const { return this->with_auth_; } bool get_is_hidden() const { return this->is_hidden_; } + bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; } int8_t get_priority() const { return priority_; } void set_priority(int8_t priority) { priority_ = priority; } diff --git a/tests/components/captive_portal/__init__.py b/tests/components/captive_portal/__init__.py new file mode 100644 index 0000000000..1ac0704a59 --- /dev/null +++ b/tests/components/captive_portal/__init__.py @@ -0,0 +1,10 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The scan list helper is header-only and needs none of the component's real + # dependencies. Pulling them in breaks the host build: web_server_base + # includes ESPAsyncWebServer.h and ota.web_server includes md5/md5.h, neither + # of which exists there. + manifest.dependencies = [] + manifest.auto_load = [] diff --git a/tests/components/captive_portal/scan_list_test.cpp b/tests/components/captive_portal/scan_list_test.cpp new file mode 100644 index 0000000000..f67581dc0b --- /dev/null +++ b/tests/components/captive_portal/scan_list_test.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include +#include + +#include "esphome/components/captive_portal/scan_list.h" + +namespace esphome::captive_portal::testing { + +namespace { + +// Stand-in for wifi::WiFiScanResult, which does not compile on the host. +struct Entry { + std::string ssid; + int8_t rssi; + bool with_auth{true}; + bool is_hidden{false}; + + // Compares length and bytes like CompactString does, so an embedded NUL counts. + bool ssid_equals(const Entry &other) const { return this->ssid == other.ssid; } + int8_t get_rssi() const { return this->rssi; } + bool get_with_auth() const { return this->with_auth; } + bool get_is_hidden() const { return this->is_hidden; } +}; + +// One row as the portal would emit it. +struct Row { + std::string ssid; + int8_t rssi; + bool lock; + + bool operator==(const Row &rhs) const { return ssid == rhs.ssid && rssi == rhs.rssi && lock == rhs.lock; } +}; + +// Walk the results the way handle_config does and collect the rows that survive. +std::vector rows(const std::vector &results) { + std::vector out; + for (size_t i = 0; i < results.size(); i++) { + bool with_auth = false; + if (!should_show_scan_entry(results, results[i], with_auth)) + continue; + out.push_back({results[i].ssid, results[i].rssi, with_auth}); + } + return out; +} + +} // namespace + +TEST(ScanList, SingleEntryShown) { + std::vector results = {{"Home", -60}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}})); +} + +TEST(ScanList, DistinctSsidsAllShownInOrder) { + std::vector results = {{"Home", -60}, {"Guest", -70}, {"Cafe", -40}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}, {"Guest", -70, true}, {"Cafe", -40, true}})); +} + +// Results are ordered by connection preference, not RSSI, so the strongest entry +// can sit anywhere in the list. +TEST(ScanList, SameSsidKeepsStrongest) { + std::vector results = {{"Home", -70}, {"Home", -50}, {"Home", -60}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -50, true}})); +} + +TEST(ScanList, EqualRssiKeepsFirst) { + std::vector results = {{"Home", -60}, {"Home", -60}, {"Home", -60}}; + bool with_auth = false; + EXPECT_TRUE(should_show_scan_entry(results, results[0], with_auth)); + EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth)); + EXPECT_FALSE(should_show_scan_entry(results, results[2], with_auth)); + EXPECT_EQ(rows(results), (std::vector{{"Home", -60, true}})); +} + +// with_auth is an out-parameter that must only be written for a shown entry. +TEST(ScanList, WithAuthUntouchedWhenNotShown) { + std::vector results = {{"Home", -50, false}, {"Home", -70, true}}; + bool with_auth = false; + EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth)); + EXPECT_FALSE(with_auth); +} + +TEST(ScanList, DuplicatesInterleavedWithOtherNetworks) { + std::vector results = {{"Home", -70}, {"Guest", -55}, {"Home", -50}, {"Guest", -65}}; + EXPECT_EQ(rows(results), (std::vector{{"Guest", -55, true}, {"Home", -50, true}})); +} + +// Hidden networks scan with an empty SSID. They are never listed and do not +// collapse into each other or into anything else. +TEST(ScanList, HiddenEntriesNeverShown) { + std::vector results = {{"", -40, true, true}, {"Home", -70}, {"", -30, true, true}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -70, true}})); +} + +// On ESP8266 the hidden flag comes from the driver alongside a real SSID, so a +// hidden access point can share its name with a visible one. It must not +// outrank that visible entry and leave the network unlisted. +TEST(ScanList, HiddenEntryDoesNotSuppressVisibleSameSsid) { + std::vector results = {{"Home", -40, true, true}, {"Home", -70}}; + EXPECT_EQ(rows(results), (std::vector{{"Home", -70, true}})); +} + +// An open access point and a secured one sharing an SSID collapse to one row that +// still asks for a password, whichever of them is strongest. +TEST(ScanList, LockSetWhenAnyEntryRequiresAuth) { + std::vector open_stronger = {{"Home", -50, false}, {"Home", -70, true}}; + EXPECT_EQ(rows(open_stronger), (std::vector{{"Home", -50, true}})); + + std::vector secured_stronger = {{"Home", -70, false}, {"Home", -50, true}}; + EXPECT_EQ(rows(secured_stronger), (std::vector{{"Home", -50, true}})); +} + +TEST(ScanList, LockClearWhenEveryEntryIsOpen) { + std::vector results = {{"Cafe", -60, false}, {"Cafe", -50, false}}; + EXPECT_EQ(rows(results), (std::vector{{"Cafe", -50, false}})); +} + +// The auth flag of an unrelated network must not leak into another SSID's row. +TEST(ScanList, LockIsPerSsid) { + std::vector results = {{"Cafe", -60, false}, {"Home", -50, true}}; + EXPECT_EQ(rows(results), (std::vector{{"Cafe", -60, false}, {"Home", -50, true}})); +} + +TEST(ScanList, EmptyListShowsNothing) { + std::vector results; + EXPECT_TRUE(rows(results).empty()); +} + +} // namespace esphome::captive_portal::testing From ea10f94376d967f2099701abd12902efdc9e7cf8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:31:47 +1200 Subject: [PATCH 330/597] [core] Add type annotations to component Python (10/11) (#18347) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/adc/__init__.py | 5 ++- esphome/components/adc/sensor.py | 6 +-- esphome/components/api/__init__.py | 36 ++++++++++++----- esphome/components/button/__init__.py | 20 ++++++---- esphome/components/climate/__init__.py | 29 ++++++++++---- esphome/components/cover/__init__.py | 40 ++++++++++++++----- .../components/dashboard_import/__init__.py | 10 +++-- esphome/components/debug/__init__.py | 3 +- esphome/components/debug/sensor.py | 3 +- esphome/components/debug/text_sensor.py | 3 +- esphome/components/esp8266/__init__.py | 18 +++++---- esphome/components/esp8266/gpio.py | 15 ++++--- esphome/components/file/image.py | 17 ++++---- esphome/components/globals/__init__.py | 12 ++++-- .../components/gpio/binary_sensor/__init__.py | 5 ++- esphome/components/gpio/one_wire/__init__.py | 3 +- esphome/components/gpio/output/__init__.py | 3 +- esphome/components/gpio/switch/__init__.py | 3 +- esphome/components/homeassistant/__init__.py | 12 ++++-- .../homeassistant/binary_sensor/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/sensor/__init__.py | 3 +- .../homeassistant/switch/__init__.py | 3 +- .../homeassistant/text_sensor/__init__.py | 3 +- .../components/homeassistant/time/__init__.py | 3 +- esphome/components/host/__init__.py | 5 ++- esphome/components/host/gpio.py | 9 +++-- esphome/components/host/time/__init__.py | 3 +- esphome/components/i2c/__init__.py | 32 ++++++++------- esphome/components/lock/__init__.py | 34 +++++++++++----- 30 files changed, 230 insertions(+), 114 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 1c50b6b81b..5c763a4f4c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( @@ -16,6 +18,7 @@ from esphome.components.esp32 import ( import esphome.config_validation as cv from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -225,7 +228,7 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { } -def validate_adc_pin(value): +def validate_adc_pin(value: Any) -> ConfigType | str: if str(value).upper() == "VCC": if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index b2a4382a21..5d1031825e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto": raise cv.Invalid("Automatic attenuation cannot be used when raw output is set") @@ -120,7 +120,7 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" -def _overlay_io_channels(): +def _overlay_io_channels() -> str: channel_count = CORE.data[CONF_ADC_CHANNEL_ID] entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) return f""" @@ -132,7 +132,7 @@ def _overlay_io_channels(): """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 912d580a0f..0dc4b905bf 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,5 +1,6 @@ import base64 import logging +from typing import Any from esphome import automation from esphome.automation import Condition @@ -129,7 +130,7 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType: return config -def validate_encryption_key(value): +def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: decoded = base64.b64decode(value, validate=True) @@ -217,7 +218,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType: return config -def _validate_supports_response(value): +def _validate_supports_response(value: Any) -> str: """Validate supports_response after auto-detection has set the value.""" return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) @@ -256,7 +257,7 @@ ENCRYPTION_SCHEMA = cv.Schema( ) -def _encryption_schema(config): +def _encryption_schema(config: ConfigType | None) -> ConfigType: if config is None: config = {} return ENCRYPTION_SCHEMA(config) @@ -393,7 +394,7 @@ async def to_code(config: ConfigType) -> None: if actions := config.get(CONF_ACTIONS, []): # Collect all triggers first, then register all at once with initializer_list - triggers: list[cg.Pvariable] = [] + triggers: list[cg.MockObj] = [] for conf in actions: func_args: list[tuple[MockObj, str]] = [] service_template_args: list[MockObj] = [] # User service argument types @@ -581,7 +582,7 @@ async def homeassistant_service_to_code( action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) @@ -647,7 +648,7 @@ async def homeassistant_service_to_code( return var -def validate_homeassistant_event(value): +def validate_homeassistant_event(value: Any) -> str: value = cv.string(value) if not value.startswith("esphome."): raise cv.Invalid( @@ -676,7 +677,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( HOMEASSISTANT_EVENT_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_event_to_code(config, action_id, template_arg, args): +async def homeassistant_event_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -724,7 +730,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): +async def homeassistant_tag_scanned_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -740,7 +751,7 @@ CONF_SUCCESS = "success" CONF_ERROR_MESSAGE = "error_message" -def _validate_api_respond_data(config): +def _validate_api_respond_data(config: ConfigType) -> ConfigType: """Set flag during validation so AUTO_LOAD can include json component.""" if CONF_DATA in config: CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True @@ -824,7 +835,12 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( @automation.register_condition( "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA ) -async def api_connected_to_code(config, condition_id, template_arg, args): +async def api_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) cg.add(var.set_state_subscription_only(templ)) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index a4245f43e6..ee24002b8a 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -88,7 +89,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("button") -async def setup_button_core_(var, config): +async def setup_button_core_(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) setup_device_class(config) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_button(var, config): +async def register_button(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("button", config) @@ -109,7 +110,7 @@ async def register_button(var, config): await setup_button_core_(var, config) -async def new_button(config, *args): +async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_button(var, config) return var @@ -125,11 +126,16 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( @automation.register_action( "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True ) -async def button_press_to_code(config, action_id, template_arg, args): +async def button_press_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(button_ns.using) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index fe050fca22..80dd913fba 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -48,13 +50,19 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import ( + LambdaExpression, + MockObj, + MockObjClass, + TemplateArgsType, +) +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -132,7 +140,7 @@ VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema( ) -def visual_temperature_step(value): +def visual_temperature_step(value: Any) -> ConfigType: # Allow defining target/current temperature steps separately if isinstance(value, dict): return VISUAL_TEMPERATURE_STEP_SCHEMA(value) @@ -273,7 +281,7 @@ def climate_schema( @setup_entity("climate") -async def setup_climate_core_(var, config): +async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") @@ -443,7 +451,7 @@ async def setup_climate_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_climate(var, config): +async def register_climate(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("climate", config) @@ -451,7 +459,7 @@ async def register_climate(var, config): await setup_climate_core_(var, config) -async def new_climate(config, *args): +async def new_climate(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_climate(var, config) return var @@ -485,7 +493,12 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( CLIMATE_CONTROL_ACTION_SCHEMA, synchronous=True, ) -async def climate_control_to_code(config, action_id, template_arg, args): +async def climate_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) # All configured fields are folded into a single stateless lambda whose @@ -549,5 +562,5 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 7639e15334..011b2c2f04 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -46,7 +46,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -162,7 +162,7 @@ _COVER_SCHEMA = ( _COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) -def _validate_mqtt_state_topics(config): +def _validate_mqtt_state_topics(config: ConfigType) -> ConfigType: if config.get(CONF_MQTT_JSON_STATE_PAYLOAD): if CONF_POSITION_STATE_TOPIC in config: raise cv.Invalid( @@ -201,7 +201,7 @@ def cover_schema( @setup_entity("cover") -async def setup_cover_core_(var, config): +async def setup_cover_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if CONF_ON_OPEN in config: @@ -235,7 +235,7 @@ async def setup_cover_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_cover(var, config): +async def register_cover(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("cover", config) @@ -243,7 +243,7 @@ async def register_cover(var, config): await setup_cover_core_(var, config) -async def new_cover(config, *args): +async def new_cover(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_cover(var, config) return var @@ -259,7 +259,12 @@ COVER_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_open_to_code(config, action_id, template_arg, args): +async def cover_open_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -267,7 +272,12 @@ async def cover_open_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_close_to_code(config, action_id, template_arg, args): +async def cover_close_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -275,7 +285,12 @@ async def cover_close_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_stop_to_code(config, action_id, template_arg, args): +async def cover_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -283,7 +298,12 @@ async def cover_stop_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_toggle_to_code(config, action_id, template_arg, args): +async def cover_toggle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -421,5 +441,5 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(cover_ns.using) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 31559a514c..c27669d77e 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -2,6 +2,7 @@ import base64 from pathlib import Path import re import secrets +from typing import Any import requests from ruamel.yaml import YAML @@ -13,6 +14,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.types import ConfigType from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -23,14 +25,14 @@ DEPENDENCIES = ["api"] CODEOWNERS = ["@esphome/core"] -def validate_import_url(value): +def validate_import_url(value: Any) -> str: value = cv.string_strict(value) value = cv.Length(max=255)(value) validate_source_shorthand(value) return value -def validate_full_url(config): +def validate_full_url(config: ConfigType) -> ConfigType: if not config[CONF_IMPORT_FULL_CONFIG]: return config source = validate_source_shorthand(config[CONF_PACKAGE_IMPORT_URL]) @@ -55,7 +57,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_ESPHOME] if CONF_PROJECT not in full_config: raise cv.Invalid( @@ -73,7 +75,7 @@ wifi: """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_DASHBOARD_IMPORT") url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 3e94d04f21..a889d13329 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CORE.using_zephyr: zephyr_add_prj_conf("HWINFO", True) # gdb thread support diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index a018ce5c3b..72e2efebc2 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if free_conf := config.get(CONF_FREE): diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index c69b8d9461..9d4fcc1b42 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ICON_CHIP, ICON_RESTART, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if CONF_DEVICE in config: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 2161a902cb..3dd9750c6f 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path import platform import re import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -31,6 +32,7 @@ from esphome.core import ( from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import IS_MACOS, copy_file_if_changed from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -88,7 +90,7 @@ def lambdas_use_scanf_float(config: ConfigType) -> bool: return False -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -102,7 +104,7 @@ def set_core_data(config): return config -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built ESP8266 firmware. Used by device-builder (esphome/device-builder), via @@ -157,7 +159,7 @@ ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1) -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(3, 1, 2), "https://github.com/esp8266/Arduino.git"), @@ -200,7 +202,7 @@ def _arduino_check_versions(value): return value -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: try: # if platform version is a valid version constraint, prefix the default package cv.platformio_version_constraint(value) @@ -275,7 +277,7 @@ def check_rosetta() -> None: @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(esp8266_ns.setup_preferences()) cg.add_platformio_option("lib_ldf_mode", "off") @@ -504,7 +506,7 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config, addr): +def _decode_pc(config: ConfigType, addr: str) -> None: from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -525,7 +527,7 @@ def _decode_pc(config, addr): _LOGGER.warning("Decoded %s", translation) -def _parse_register(config, regex, line): +def _parse_register(config: ConfigType, regex: re.Pattern[str], line: str) -> None: match = regex.match(line) if match is not None: _decode_pc(config, match.group(1)) @@ -549,7 +551,7 @@ STACKTRACE_BAD_ALLOC_RE = re.compile( STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") -def process_stacktrace(config, line, backtrace_state): +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: line = line.strip() # ESP8266 Exception type match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line) diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 64be4a6495..356af6e006 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -18,6 +19,8 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, esp8266_ns @@ -27,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) ESP8266GPIOPin = esp8266_ns.class_("ESP8266GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_ESP8266][KEY_BOARD] board_pins = boards.ESP8266_BOARD_PINS.get(board, {}) @@ -42,7 +45,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -69,7 +72,7 @@ _ESP_SDIO_PINS = { } -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) if value < 0 or value > 17: raise cv.Invalid(f"ESP8266: Invalid pin number: {value}") @@ -86,7 +89,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] @@ -160,7 +163,7 @@ class PinInitialState: @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP8266, ESP8266_PIN_SCHEMA) -async def esp8266_pin_to_code(config): +async def esp8266_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] mode = config[CONF_MODE] @@ -192,7 +195,7 @@ async def esp8266_pin_to_code(config): @coroutine_with_priority(CoroPriority.WORKAROUNDS) -async def add_pin_initial_states_array(): +async def add_pin_initial_states_array() -> None: # Add includes at the very end, so that they override everything initial_states: list[PinInitialState] = CORE.data[KEY_ESP8266][ KEY_PIN_INITIAL_STATES diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index feced063d0..7cef7c754a 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -5,6 +5,7 @@ import io import logging from pathlib import Path import re +from typing import Any from PIL import Image, UnidentifiedImageError @@ -75,12 +76,12 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value): +def local_path(value: str | ConfigType) -> str: value = value[CONF_PATH] if isinstance(value, dict) else value return str(CORE.relative_config_path(value)) -def download_file(url, path): +def download_file(url: str, path: Path) -> str: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) @@ -98,7 +99,7 @@ def download_gh_svg(value: str | ConfigType, source: str) -> str: return download_file(url, path) -def download_image(value): +def download_image(value: str | ConfigType) -> str: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -146,7 +147,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value): +def validate_file_shorthand(value: Any) -> str: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -163,8 +164,8 @@ LOCAL_SCHEMA = cv.All( ) -def mdi_schema(source): - def validate_mdi(value): +def mdi_schema(source: str) -> cv.All: + def validate_mdi(value: ConfigType) -> str: return download_gh_svg(value, source) return cv.All( @@ -259,7 +260,9 @@ async def new_image(config: ConfigType) -> MockObj: return var -async def write_image(config, all_frames=False): +async def write_image( + config: ConfigType, all_frames: bool = False +) -> tuple[MockObj, int, int, MockObj, MockObj, int]: path = Path(config[CONF_FILE]) if not path.is_file(): raise core.EsphomeError(f"Could not load image file {path}") diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 46725fe6dd..bd6bc5f783 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,7 +8,8 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -62,7 +63,7 @@ CONFIG_SCHEMA = _globals_schema # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] @@ -104,7 +105,12 @@ async def to_code(config): ), synchronous=True, ) -async def globals_set_to_code(config, action_id, template_arg, args): +async def globals_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 703806670c..7cc16eb5b2 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_PIN, ) from esphome.core import CORE +from esphome.types import ConfigType from .. import gpio_ns @@ -68,7 +69,7 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config) -> None: +def _final_validate(config: ConfigType) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: return @@ -124,7 +125,7 @@ def _final_validate(config) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index e2bb94dd66..feb8b53dff 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index 786e04bac0..ab242c643f 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 9462cd0161..2e0b0969bc 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_INTERLOCK, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 7b23775b47..1b66842f1e 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,13 +1,19 @@ +from collections.abc import Callable, Iterable + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] homeassistant_ns = cg.esphome_ns.namespace("homeassistant") -def validate_entity_domain(platform, supported_domains): - def validator(config): +def validate_entity_domain( + platform: str, supported_domains: Iterable[str] +) -> Callable[[ConfigType], ConfigType]: + def validator(config: ConfigType) -> ConfigType: domain = config[CONF_ENTITY_ID].split(".", 1)[0] if domain not in supported_domains: raise cv.Invalid( @@ -34,7 +40,7 @@ HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( ) -def setup_home_assistant_entity(var, config): +def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index a943368dd7..6ea17b6831 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 8f760772c3..ab1389e13a 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await number.new_number( config, diff --git a/esphome/components/homeassistant/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index 6437476827..abee957fda 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index c299a731f2..55854cd659 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/homeassistant/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index b59f9d23df..265250c695 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 05ca86a26e..146b8278ea 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TIMEZONE +from esphome.types import ConfigType from .. import homeassistant_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await time_.register_time(var, config) await cg.register_component(var, config) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index b6a3b8b615..c5846f5406 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.platformio.toolchain import copy_ccache_script +from esphome.types import ConfigType from .const import KEY_HOST @@ -22,7 +23,7 @@ AUTO_LOAD = ["network", "preferences"] IS_TARGET_PLATFORM = True -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_HOST] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_HOST CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "host" @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_HOST") cg.add_define("USE_NATIVE_64BIT_TIME") # The prefs file finds stored preferences by key, so key migration is possible diff --git a/esphome/components/host/gpio.py b/esphome/components/host/gpio.py index fcfb0b6c54..e39d35d077 100644 --- a/esphome/components/host/gpio.py +++ b/esphome/components/host/gpio.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -14,6 +15,8 @@ from esphome.const import ( CONF_PULLDOWN, CONF_PULLUP, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from .const import host_ns @@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) HostGPIOPin = host_ns.class_("HostGPIOPin", cg.InternalGPIOPin) -def _translate_pin(value): +def _translate_pin(value: Any) -> int | str: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -41,7 +44,7 @@ def _translate_pin(value): return value -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int | str: return _translate_pin(value) @@ -53,7 +56,7 @@ HOST_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register("host", HOST_PIN_SCHEMA) -async def host_pin_to_code(config): +async def host_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/host/time/__init__.py b/esphome/components/host/time/__init__.py index d9a2f1207c..6eb0cf954d 100644 --- a/esphome/components/host/time/__init__.py +++ b/esphome/components/host/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -14,7 +15,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await time_.register_time(var, config) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 94aad4d019..b053125446 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,6 +1,7 @@ import logging import re import sys +from typing import Any from esphome import pins import esphome.codegen as cg @@ -52,9 +53,10 @@ from esphome.const import ( PLATFORM_RP2, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] @@ -96,13 +98,13 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True -def validate_device(value): +def validate_device(value: str) -> str: if not re.match(r"^/(?:[^/]+/)*[^/]+$", value): raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)") return value -def _bus_declare_type(value): +def _bus_declare_type(value: Any) -> ID: if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: @@ -114,7 +116,7 @@ def _bus_declare_type(value): raise NotImplementedError -def _rp2040_i2c_controller(pin): +def _rp2040_i2c_controller(pin: int) -> int: """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): @@ -125,7 +127,7 @@ def _rp2040_i2c_controller(pin): return (pin // 2) % 2 -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) @@ -142,7 +144,7 @@ def validate_config(config): return config -def validate_host_config(config): +def validate_host_config(config: ConfigType) -> ConfigType: if CORE.is_host: # Host I2C is currently only supported on Linux if not sys.platform.lower().startswith("linux"): @@ -229,7 +231,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") @@ -281,7 +283,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.BUS) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") if CORE.is_esp32: @@ -358,7 +360,7 @@ async def to_code(config): cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) -def i2c_device_schema(default_address): +def i2c_device_schema(default_address: int | None) -> cv.Schema: """Create a schema for a i2c device. :param default_address: The default address of the i2c device, can be None to represent @@ -375,7 +377,7 @@ def i2c_device_schema(default_address): return cv.Schema(schema) -async def register_i2c_device(var, config): +async def register_i2c_device(var: MockObj, config: ConfigType) -> None: """Register an i2c device with the given config. Sets the i2c bus to use and the i2c address. @@ -390,11 +392,11 @@ async def register_i2c_device(var, config): def final_validate_device_schema( name: str, *, - min_frequency: cv.frequency = None, - max_frequency: cv.frequency = None, - min_timeout: cv.time_period = None, - max_timeout: cv.time_period = None, -): + min_frequency: Any = None, + max_frequency: Any = None, + min_timeout: Any = None, + max_timeout: Any = None, +) -> cv.Schema: hub_schema = {} if (min_frequency is not None) and (max_frequency is not None): hub_schema[cv.Required(CONF_FREQUENCY)] = cv.Range( diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 0a8ad58bc2..a4a7b5237d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,13 +12,14 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -102,7 +103,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("lock") -async def _setup_lock_core(var, config): +async def _setup_lock_core(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id := config.get(CONF_MQTT_ID): @@ -113,7 +114,7 @@ async def _setup_lock_core(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_lock(var, config): +async def register_lock(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("lock", config) @@ -121,7 +122,7 @@ async def register_lock(var, config): await _setup_lock_core(var, config) -async def new_lock(config, *args): +async def new_lock(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_lock(var, config) return var @@ -143,23 +144,38 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True ) -async def lock_action_to_code(config, action_id, template_arg, args): +async def lock_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code(config, condition_id, template_arg, args): +async def lock_is_on_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code(config, condition_id, template_arg, args): +async def lock_is_off_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(lock_ns.using) From 74fc2e367abb874d74c436d9961d7ff3d9981a11 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:42:53 +0000 Subject: [PATCH 331/597] Bump bundled esphome-device-builder to 1.12.4 (#18651) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4cde6505b3..9f27d51059 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.12.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.12.4 RUN \ platformio settings set enable_telemetry No \ From d119ad6c6078fd8fa3d2191c4d5e2b91fbc7a38e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sat, 22 Aug 2026 17:47:46 +0200 Subject: [PATCH 332/597] [usb_uart] Extract non-final USBUartChannelBase from USBUartChannel (#17472) Co-authored-by: p1ngb4ck --- esphome/components/usb_uart/ch34x.cpp | 2 +- esphome/components/usb_uart/cp210x.cpp | 2 +- esphome/components/usb_uart/ft23xx.cpp | 6 +-- esphome/components/usb_uart/pl2303.cpp | 2 +- esphome/components/usb_uart/usb_uart.cpp | 20 ++++---- esphome/components/usb_uart/usb_uart.h | 65 ++++++++++++++---------- 6 files changed, 55 insertions(+), 42 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index abfed74f94..00c5e0b069 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -95,7 +95,7 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { uint8_t cmd = 0xA1 + channel->index_; if (channel->index_ >= 2) diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 2722ec8555..5551abe1a1 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -97,7 +97,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). if (reload) diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 79aa107d72..fcebf0fbd9 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -270,7 +270,7 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { +void USBUartTypeFT23XX::start_input(USBUartChannelBase *channel) { if (!channel->initialised_.load()) return; @@ -336,12 +336,12 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { } } -void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannelBase *channel) { ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); channel->input_buffer_.clear(); } -bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios // path only re-applies baud + line properties and does not re-assert DTR/RTS. diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index c56f43f75a..a9f7348331 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -226,7 +226,7 @@ static const Pl2303InitStep PL2303_INIT[] = { }; static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); -bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index c289625f1a..cf66e4c369 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -136,7 +136,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { } return len; } -void USBUartChannel::write_array(const uint8_t *data, size_t len) { +void USBUartChannelBase::write_array(const uint8_t *data, size_t len) { if (!this->initialised_.load()) { ESP_LOGD(TAG, "Channel not initialised - write ignored"); return; @@ -170,7 +170,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { this->parent_->start_output(this); } -uart::UARTFlushResult USBUartChannel::flush() { +uart::UARTFlushResult USBUartChannelBase::flush() { // Spin until the output queue is drained and the last USB transfer completes. // Safe to call from the main loop only. // The flush_timeout_ms_ timeout guards against a device that stops responding mid-flush; @@ -186,14 +186,14 @@ uart::UARTFlushResult USBUartChannel::flush() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } -bool USBUartChannel::peek_byte(uint8_t *data) { +bool USBUartChannelBase::peek_byte(uint8_t *data) { if (this->input_buffer_.is_empty()) { return false; } *data = this->input_buffer_.peek(); return true; } -bool USBUartChannel::read_array(uint8_t *data, size_t len) { +bool USBUartChannelBase::read_array(uint8_t *data, size_t len) { if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - read ignored"); return false; @@ -277,7 +277,7 @@ void USBUartComponent::dump_config() { YESNO(channel->dummy_receiver_)); } } -void USBUartComponent::start_input(USBUartChannel *channel) { +void USBUartComponent::start_input(USBUartChannelBase *channel) { if (!channel->initialised_.load()) return; // THREAD CONTEXT: Called from both USB task and main loop threads @@ -346,7 +346,7 @@ void USBUartComponent::start_input(USBUartChannel *channel) { } } -void USBUartComponent::start_output(USBUartChannel *channel) { +void USBUartComponent::start_output(USBUartChannelBase *channel) { // THREAD CONTEXT: Called from both main loop and USB task threads. // The output_queue_ is a lock-free SPSC queue, so pop() is safe from either thread. // The output_started_ atomic flag is claimed via compare_exchange to guarantee that @@ -491,7 +491,7 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, +bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; @@ -537,7 +537,7 @@ void USBUartComponent::enable_channels() { this->start_config_(false); } -void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { +void USBUartComponent::apply_channel_settings(USBUartChannelBase *channel) { if (this->cfg_active_) { // A config sequence is already running. Defer this reload until it finishes to preserve // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an @@ -620,7 +620,7 @@ bool USBUartComponent::run_config_machine_() { this->cfg_ok_ = true; } - USBUartChannel *channel = + USBUartChannelBase *channel = this->cfg_single_ != nullptr ? this->cfg_single_ : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); @@ -664,7 +664,7 @@ bool USBUartComponent::run_config_machine_() { return true; } -void USBUartChannel::load_settings(bool /*dump_config*/) { +void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 5bb4c97796..00b34fb942 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -16,7 +16,7 @@ namespace esphome::usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; -class USBUartChannel; +class USBUartChannelBase; class USBUartTypePL2303; static const char *const TAG = "usb_uart"; @@ -110,7 +110,7 @@ class RingBuffer { struct UsbDataChunk { uint8_t data[usb_host::USB_MAX_PACKET_SIZE]; uint16_t length; - USBUartChannel *channel; + USBUartChannelBase *channel; // Required for EventPool - no cleanup needed for POD types void release() {} @@ -126,7 +126,11 @@ struct UsbOutputChunk { void release() {} }; -class USBUartChannel final : public uart::UARTComponent, public Parented { +// Common, non-final base for all USB UART channel implementations. +// Concrete channel types (USBUartChannel for CDC-style devices, vendor-specific +// multiplexed channels like CH934X) derive from this and are themselves final, +// per the "configurable classes are final" convention. +class USBUartChannelBase : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; @@ -139,7 +143,6 @@ class USBUartChannel final : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + // Not directly instantiable; construct a concrete channel type instead. + USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; @@ -185,33 +190,40 @@ class USBUartChannel final : public uart::UARTComponent, public Parented get_channels() { return this->channels_; } + std::vector get_channels() { return this->channels_; } - void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } + void add_channel(USBUartChannelBase *channel) { this->channels_.push_back(channel); } - virtual void start_input(USBUartChannel *channel); - void start_output(USBUartChannel *channel); + virtual void start_input(USBUartChannelBase *channel); + void start_output(USBUartChannelBase *channel); // Begin configuring all channels (full initialisation). Called from on_connected(). void enable_channels(); // Re-apply line settings to a single, already-open channel (used by - // USBUartChannel::load_settings()). - void apply_channel_settings(USBUartChannel *channel); + // USBUartChannelBase::load_settings()). + void apply_channel_settings(USBUartChannelBase *channel); // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. // Default is a no-op; override in device-specific subclasses that need resync on overflow. - virtual void on_rx_overflow(USBUartChannel *channel) {} + virtual void on_rx_overflow(USBUartChannelBase *channel) {} // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; - // Pool sized to queue capacity (SIZE-1) — see USBUartChannel::output_pool_ comment. + // Pool sized to queue capacity (SIZE-1) — see USBUartChannelBase::output_pool_ comment. EventPool chunk_pool_; protected: @@ -231,18 +243,19 @@ class USBUartComponent : public usb_host::USBClient { // next control transfer via config_transfer_() and return true, or return false when the // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. - virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + virtual bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) = 0; // Optional one-time device-level setup run before the per-channel phase on init only // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } - std::vector channels_{}; + std::vector channels_{}; // Config state machine - USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel - USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy - std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads - uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + USBUartChannelBase *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannelBase *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; bool cfg_active_{false}; @@ -260,7 +273,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -269,7 +282,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -277,7 +290,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; @@ -291,12 +304,12 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel) override; - void on_rx_overflow(USBUartChannel *channel) override; + void start_input(USBUartChannelBase *channel) override; + void on_rx_overflow(USBUartChannelBase *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -312,14 +325,14 @@ enum Pl2303ChipType : uint8_t { }; class USBUartTypePL2303 : public USBUartTypeCdcAcm { - friend class USBUartChannel; + friend class USBUartChannelBase; public: USBUartTypePL2303(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; From dcabaedff1b5adfb7d2070b7d1557e67d0eca8c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 18:59:24 -0500 Subject: [PATCH 333/597] [bk72xx_ble] Block BK7238 until the LibreTiny bonding partition fix lands (#18649) --- esphome/components/bk72xx_ble/__init__.py | 30 +++++++++---------- .../bk72xx_ble/config/test_bk7238.yaml | 7 +++++ .../bk72xx_ble/test_family_gate.py | 1 + 3 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_bk7238.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 81073c9b02..74b9cb5954 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -4,9 +4,12 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build on this component and contain no SDK calls of their own. -Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in -to_code; unknown families are capability-checked at compile time via +Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2), +and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE +compiled in, the Beken SDK erases the bootloader flash sector at boot because +LibreTiny's partition table has no BLE bonding entry (esphome#18646, +libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in +to_code. Unknown families are capability-checked at compile time via `__has_include("app_ble.h")`, a header only on the BLE 5.x include path (ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build fails with a clear #error. @@ -65,6 +68,14 @@ def _unsupported_family_message(family: str) -> str | None: ) if family == FAMILY_BK7231Q: return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + if family == FAMILY_BK7238: + return ( + "bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK " + "erases the bootloader flash sector at boot and the device can no longer " + "start (see https://github.com/esphome/esphome/issues/18646); support " + "returns once the LibreTiny partition table fix " + "(libretiny-eu/libretiny#408) is released" + ) return None @@ -113,18 +124,7 @@ async def to_code(config: ConfigType) -> None: # BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is # derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++ # which path is available so it doesn't reference a missing symbol. - family = libretiny.get_libretiny_family() - if family == FAMILY_BK7231N: + if libretiny.get_libretiny_family() == FAMILY_BK7231N: cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") - elif family == FAMILY_BK7238: - # ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at - # WiFi STA startup when BLE init runs. This component re-enables BLE, so - # warn loudly: BK7238 is accepted but not hardware-verified and may be - # WiFi-unstable with BLE on. - _LOGGER.warning( - "bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup " - "hang on this family and is not yet hardware-verified. Expect possible " - "instability." - ) cg.add_define("USE_BK72XX_BLE") diff --git a/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml b/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml new file mode 100644 index 0000000000..0880cf69f5 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_bk7238.yaml @@ -0,0 +1,7 @@ +esphome: + name: bk-family-gate-7238 + +bk72xx: + board: generic-bk7238 + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_family_gate.py b/tests/component_tests/bk72xx_ble/test_family_gate.py index da67749bb3..86f3ef0039 100644 --- a/tests/component_tests/bk72xx_ble/test_family_gate.py +++ b/tests/component_tests/bk72xx_ble/test_family_gate.py @@ -16,6 +16,7 @@ from esphome.core import EsphomeError ("test_bk7231t.yaml", "BK7231T.*BLE 4.2"), ("test_bk7252.yaml", "BK7251.*BLE 4.2"), ("test_bk7231q.yaml", "BK7231Q.*no BLE"), + ("test_bk7238.yaml", "BK7238.*bootloader"), ], ) def test_unsupported_family_rejected( From c062d0c7171a1e576fdb0bd8864e374be51a707c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:13 -0500 Subject: [PATCH 334/597] [ota] Log prepare, upload, and total OTA timing in espota2 (#18582) --- esphome/espota2.py | 18 ++++++++++++++++++ tests/unit_tests/test_espota2.py | 28 ++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 61e897f601..ca833f1816 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -460,8 +460,14 @@ def perform_ota( (upload_size >> 8) & 0xFF, (upload_size >> 0) & 0xFF, ] + # The device erases flash between receiving the size and acking the + # prepare, so this window shows the erase cost (near zero when the + # device erases lazily during the upload) + prepare_start = time.perf_counter() send_check(sock, upload_size_encoded, "binary size") receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK) + prepare_duration = time.perf_counter() - prepare_start + _LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration) upload_md5 = hashlib.md5(upload_contents).hexdigest() _LOGGER.debug("MD5 of upload is %s", upload_md5) @@ -528,11 +534,23 @@ def perform_ota( # reboots on its own; the exact commit point is not observable from # here, so treat everything past the data phase as non-retryable. A # re-upload could flash a device that already updated successfully. + commit_start = time.perf_counter() try: receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK) receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK) except OTANetworkError as err: raise _committed_error(err) from err + commit_duration = time.perf_counter() - commit_start + + # Sum of the named windows so the breakdown is self consistent; connect, + # handshake, auth, and the one MD5 round trip are not included + _LOGGER.info( + "Update took %.2f seconds (prepare %.2f, upload %.2f, commit %.2f)", + prepare_duration + duration + commit_duration, + prepare_duration, + duration, + commit_duration, + ) try: send_check(sock, RESPONSE_OK, "end acknowledgement") diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index db4a4b1117..e0e9185e1c 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -6,6 +6,8 @@ from collections.abc import Generator import gzip import hashlib import io +import itertools +import logging from pathlib import Path import socket import struct @@ -53,8 +55,9 @@ def mock_sleep() -> Generator[Mock]: @pytest.fixture def mock_time(mock_sleep: Mock) -> Generator[None]: """Mock time-related functions for consistent testing.""" - # Provide enough values for multiple calls (tests may call perform_ota multiple times) - with patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]): + # Monotonically increasing, never exhausted regardless of how many timing + # windows perform_ota measures or how many times a test calls it + with patch("time.perf_counter", side_effect=itertools.count()): yield @@ -372,7 +375,9 @@ def test_perform_ota_successful_md5_auth( @pytest.mark.usefixtures("mock_time") -def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: +def test_perform_ota_no_auth( + mock_socket: Mock, mock_file: io.BytesIO, caplog: pytest.LogCaptureFixture +) -> None: """Test OTA without authentication.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response @@ -387,7 +392,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, None, mock_file, "test.bin") + # Distinct window lengths pin each duration to its label; exactly the 6 + # expected perf_counter calls, so an unaccounted timing window raises + timings = [0.0, 2.0, 10.0, 15.0, 20.0, 27.0] + with ( + patch("time.perf_counter", side_effect=timings), + caplog.at_level(logging.INFO), + ): + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # Should not send any auth-related data auth_calls = [ @@ -397,6 +409,14 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: ] assert len(auth_calls) == 0 + # The timing summary is the observable output of the upload; exact strings + # pin each duration to its label + assert "Preparing for upload took 2.00 seconds" in caplog.text + assert ( + "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" + in caplog.text + ) + @pytest.mark.usefixtures("mock_time") def test_perform_ota_with_compression(mock_socket: Mock) -> None: From 1f31e51446af7bf7d6a34c8dfc40861a9a7ce783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:37 -0500 Subject: [PATCH 335/597] [esphome] Inline the trivial OTA port accessors (#18625) --- esphome/components/esphome/ota/ota_esphome.cpp | 2 -- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cab725f704..9cbb25b373 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -588,8 +588,6 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { } float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } -void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0053ca6969..979e3f2d7d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -39,14 +39,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD /// Manually set the port OTA should listen on - void set_port(uint16_t port); + void set_port(uint16_t port) { this->port_ = port; } void setup() override; void dump_config() override; float get_setup_priority() const override; void loop() override; - uint16_t get_port() const; + uint16_t get_port() const { return this->port_; } protected: void handle_handshake_(); From 6aab523dd9e6c716d1f2f246598bbc786954ef0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:57:58 -0500 Subject: [PATCH 336/597] [esp32_ble] Log connection parameter update results (#18607) --- esphome/components/esp32_ble/ble.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index e2d79173ff..6e6fb0e30d 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -643,8 +643,28 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa App.wake_loop_threadsafe(); return; + // Log the result of connection parameter updates: a peer can reject or + // never answer an update, and without this the link silently stays on the + // old parameters (visible only as unexplained supervision timeouts). + case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: { + if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status); + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + else { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s, + param->update_conn_params.conn_int, param->update_conn_params.latency, + param->update_conn_params.timeout); + } +#endif + return; + } + // Ignore these GAP events as they are not relevant for our use case - case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm From 14499223fd1e1560faf034747f39bd2b2c28f8a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:58:13 -0500 Subject: [PATCH 337/597] [esp8266] Don't report stale crash state after hardware WDT resets (#18597) --- esphome/components/esp8266/crash_handler.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 91b0cf9082..dc79043f21 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -118,8 +118,6 @@ static const LogString *get_exception_cause(uint32_t cause) { } static const LogString *get_reset_reason(uint32_t reason) { - if (reason == REASON_WDT_RST) - return LOG_STR("Hardware WDT"); if (reason == REASON_EXCEPTION_RST) return LOG_STR("Exception"); if (reason == REASON_SOFT_WDT_RST) @@ -162,13 +160,20 @@ void crash_handler_log() { if (!is_crash_reason(resetInfo.reason)) return; + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + if (resetInfo.reason == REASON_WDT_RST) { + // A hardware WDT reset happens entirely in hardware: the postmortem hook + // never runs, so rst_info epc1/exccause and the RTC backtrace are + // leftovers from an earlier crash. Don't misattribute them (#18596). + ESP_LOGE(TAG, " Reason: Hardware WDT (no crash state is recorded for hardware WDT resets)"); + return; + } + // Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost). // Both resetInfo and RTC data survive until the next reset, so this can be // called multiple times (logger init + API subscribe) with the same result. uint32_t backtrace[MAX_BACKTRACE]; uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); - - ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match // the Arduino core's postmortem handler behavior. From 74bdf275d20ab138cd9950e4ee281a11c21b39cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:58:28 -0500 Subject: [PATCH 338/597] [core] Dump the main.cpp config comment with sorted keys (#18653) --- esphome/__main__.py | 6 ++++-- tests/unit_tests/test_main.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c1e05d2ea7..769b66ecc8 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -762,9 +762,11 @@ def _wrap_to_code(name, comp, yaml_util): async def wrapped(conf): cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: - conf_str = yaml_util.dump(conf) + # sort_keys: voluptuous fills defaults in set order, so an + # unsorted dump would churn main.cpp and relink every run + conf_str = yaml_util.dump(conf, sort_keys=True) conf_str = conf_str.replace("//", "") - # remove tailing \ to avoid multi-line comment warning + # remove trailing \ to avoid multi-line comment warning conf_str = conf_str.replace("\\\n", "\n") cg.add(cg.LineComment(indent(conf_str))) await coro(conf) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a40341e194..1cb710ca58 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -11,6 +11,7 @@ from pathlib import Path import re import sys import time +from types import SimpleNamespace from typing import Any, Self from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -18,7 +19,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange -from esphome import __main__ as main +from esphome import __main__ as main, yaml_util from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -29,6 +30,7 @@ from esphome.__main__ import ( _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, + _wrap_to_code, check_permissions, choose_upload_log_host, command_analyze_memory, @@ -116,6 +118,7 @@ from esphome.espota2 import ( OTA_TYPE_UPDATE_PARTITION_TABLE, ) from esphome.platformio import toolchain +from esphome.types import ConfigType from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -7130,3 +7133,28 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails( # Same tree, so the path comparison still finds them equal and stays silent assert not caplog.text + + +@pytest.mark.asyncio +async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: + """The config comment dumps with sorted keys: voluptuous fills schema + defaults in set-iteration order, so an unsorted dump would churn + main.cpp and relink the firmware on every run.""" + comments: list[str] = [] + + async def to_code(conf: ConfigType) -> None: + """Accept any config; only the wrapper's comment output matters.""" + + comp = SimpleNamespace(to_code=to_code, config_schema=object()) + wrapped = _wrap_to_code("demo", comp, yaml_util) + with patch("esphome.codegen.add", side_effect=lambda st: comments.append(str(st))): + # Nested on purpose: the real churn lives in nested action configs, + # so sorting must apply at every mapping level + await wrapped({"beta": 1, "alpha": {"z": 1, "a": 2}}) + first = "\n".join(comments) + comments.clear() + await wrapped({"alpha": {"a": 2, "z": 1}, "beta": 1}) + second = "\n".join(comments) + assert first == second + assert second.index("alpha") < second.index("beta") + assert second.index("a: 2") < second.index("z: 1") From 259e7182a350e16b1f70fe88a5bb0dff7fbfc546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 20:59:55 -0500 Subject: [PATCH 339/597] [esp32] Exclude esp_gdbstub from the build by default (#18604) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d6ed6d9399..501c2e525f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -233,6 +233,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component + "esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_http_client", # HTTP client - only needed by http_request component "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation From a282cb095ec2dc4bb08e4109714b4d71768c4629 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:39 -0500 Subject: [PATCH 340/597] [ethernet] Inline the trivial EthernetComponent setters (#18618) --- .../ethernet/ethernet_component.cpp | 8 ---- .../components/ethernet/ethernet_component.h | 48 +++++++++---------- .../ethernet/ethernet_component_esp32.cpp | 20 +------- .../ethernet/ethernet_component_rp2.cpp | 7 --- 4 files changed, 25 insertions(+), 58 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 42cb0b3cfc..14a4fd660b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -10,14 +10,6 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non- EthernetComponent::EthernetComponent() { global_eth_component = this; } -float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } - -void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } - -#ifdef USE_ETHERNET_MANUAL_IP -void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } -#endif - #ifdef USE_ETHERNET_IP_STATE_LISTENERS void EthernetComponent::notify_ip_state_listeners_() { auto ips = this->get_ip_addresses(); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 646e0af8e6..2da070b5e0 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -125,7 +125,7 @@ class EthernetComponent final : public Component { void setup() override; void loop() override; void dump_config() override; - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::ETHERNET; } void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } @@ -146,9 +146,9 @@ class EthernetComponent final : public Component { esp_netif_t *get_esp_netif() { return this->eth_netif_; } #endif - void set_type(EthernetType type); + void set_type(EthernetType type) { this->type_ = type; } #ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); + void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } #endif void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } @@ -171,35 +171,35 @@ class EthernetComponent final : public Component { esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } #ifdef USE_ETHERNET_SPI - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(uint8_t interrupt_pin); - void set_reset_pin(uint8_t reset_pin); - void set_clock_speed(int clock_speed); - void set_interface(spi_host_device_t interface); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } + void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } + void set_interface(spi_host_device_t interface) { this->interface_ = interface; } #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - void set_polling_interval(uint32_t polling_interval); + void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif #else - void set_phy_addr(uint8_t phy_addr); - void set_power_pin(int power_pin); - void set_mdc_pin(uint8_t mdc_pin); - void set_mdio_pin(uint8_t mdio_pin); - void set_clk_pin(uint8_t clk_pin); - void set_clk_mode(emac_rmii_clock_mode_t clk_mode); + void set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } + void set_power_pin(int power_pin) { this->power_pin_ = power_pin; } + void set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } + void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } void add_phy_register(PHYRegister register_value); #endif // USE_ETHERNET_SPI #endif // USE_ESP32 #ifdef USE_RP2 - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(int8_t interrupt_pin); - void set_reset_pin(int8_t reset_pin); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } #endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 0220d6a19b..4af2d5f93c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -908,25 +908,7 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifdef USE_ETHERNET_SPI -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } -void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } -void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT -void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } -#endif -#else -void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } -void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } -void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } -void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } +#ifndef USE_ETHERNET_SPI void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 119e447689..7f4db4fab7 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -355,13 +355,6 @@ void EthernetComponent::dump_connect_params_() { this->get_eth_mac_address_pretty_into_buffer(mac_buf)); } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } - void EthernetComponent::enable() { // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally; // there is no clean enable/disable hook today. The YAML option is accepted on From 0dc69aab1e3f6927cd6ee33804c69e2404a0728b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:49 -0500 Subject: [PATCH 341/597] [logger] Inline the trivial Logger accessors (#18619) --- esphome/components/logger/logger.cpp | 7 ------- esphome/components/logger/logger.h | 6 +++--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 6527b6aa8c..bfc005070e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -201,17 +201,10 @@ void Logger::process_messages_() { #endif // USE_ESPHOME_TASK_LOG_BUFFER } -void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) -UARTSelection Logger::get_uart() const { return this->uart_; } -#endif - -float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } - // Log level strings - packed into flash on ESP8266, indexed by log level (0-7) PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"); diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 69d8e6d32a..9c26814f7e 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -148,7 +148,7 @@ class Logger final : public Component { void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. - void set_baud_rate(uint32_t baud_rate); + void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } uint32_t get_baud_rate() const { return baud_rate_; } #if defined(USE_ARDUINO) && !defined(USE_ESP32) Stream *get_hw_serial() const { return hw_serial_; } @@ -163,7 +163,7 @@ class Logger final : public Component { #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. - UARTSelection get_uart() const; + UARTSelection get_uart() const { return this->uart_; } #endif /// Set the default log level for this logger. @@ -197,7 +197,7 @@ class Logger final : public Component { void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); } #endif - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT #ifdef USE_STORE_LOG_STR_IN_FLASH From 4db16660242dc4db15bef9fd3ab2bc3b96ce8ed7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:00:59 -0500 Subject: [PATCH 342/597] [light] Inline the trivial LightState accessors (#18620) --- esphome/components/light/esp_range_view.cpp | 2 -- esphome/components/light/esp_range_view.h | 3 +++ esphome/components/light/light_state.cpp | 18 ------------- esphome/components/light/light_state.h | 28 ++++++++++++--------- 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 58d552031a..5d372983d9 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -13,8 +13,6 @@ ESPColorView ESPRangeView::operator[](int32_t index) const { index = interpret_index(index, this->size()) + this->begin_; return (*this->parent_)[index]; } -ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } -ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } void ESPRangeView::set(const Color &color) { for (int32_t i = this->begin_; i < this->end_; i++) { diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index f5e4ebb83f..ec129bdf70 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -75,4 +75,7 @@ class ESPRangeIterator { int32_t i_; }; +inline ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } +inline ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } + } // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9d0181a05c..82c00e2382 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -157,8 +157,6 @@ void LightState::loop() { } } -float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } - void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { @@ -194,25 +192,11 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::set_default_transition_length(uint32_t default_transition_length) { - this->default_transition_length_ = default_transition_length; -} -uint32_t LightState::get_default_transition_length() const { return this->default_transition_length_; } -void LightState::set_flash_transition_length(uint32_t flash_transition_length) { - this->flash_transition_length_ = flash_transition_length; -} -uint32_t LightState::get_flash_transition_length() const { return this->flash_transition_length_; } -void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } -void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } -void LightState::set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } -bool LightState::supports_effects() { return !this->effects_.empty(); } -const FixedVector &LightState::get_effects() const { return this->effects_; } void LightState::add_effects(const std::initializer_list &effects) { // Called once from Python codegen during setup with all effects from YAML config this->effects_ = effects; } -void LightState::current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -333,8 +317,6 @@ float LightState::gamma_uncorrect_lut(float value) const { } #endif // USE_LIGHT_GAMMA_LUT -bool LightState::is_transformer_active() { return this->is_transformer_active_; } - void LightState::start_effect_(uint32_t effect_index) { this->stop_effect_(); if (effect_index == 0) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 5efc05358b..3a3f8fc368 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -109,7 +109,7 @@ class LightState : public EntityBase, public Component { void dump_config() override; void loop() override; /// Shortly after HARDWARE. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::HARDWARE - 1.0f; } /** The current values of the light as outputted to the light. * @@ -157,15 +157,19 @@ class LightState : public EntityBase, public Component { void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. - void set_default_transition_length(uint32_t default_transition_length); - uint32_t get_default_transition_length() const; + void set_default_transition_length(uint32_t default_transition_length) { + this->default_transition_length_ = default_transition_length; + } + uint32_t get_default_transition_length() const { return this->default_transition_length_; } /// Set the flash transition length - void set_flash_transition_length(uint32_t flash_transition_length); - uint32_t get_flash_transition_length() const; + void set_flash_transition_length(uint32_t flash_transition_length) { + this->flash_transition_length_ = flash_transition_length; + } + uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct); + void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } float get_gamma_correct() const { return this->gamma_correct_; } #ifdef USE_LIGHT_GAMMA_LUT @@ -186,17 +190,17 @@ class LightState : public EntityBase, public Component { #endif // USE_LIGHT_GAMMA_LUT /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode); + void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } /// Set a callback to populate the initial state defaults during setup. /// The callback is called once, then cleared. Values live in flash as code. - void set_initial_state(void (*callback)(LightStateRTCState &)); + void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } /// Return whether the light has any effects that meet the trait requirements. - bool supports_effects(); + bool supports_effects() const { return !this->effects_.empty(); } /// Get all effects for this light state. - const FixedVector &get_effects() const; + const FixedVector &get_effects() const { return this->effects_; } /// Add effects for this light state. void add_effects(const std::initializer_list &effects); @@ -254,7 +258,7 @@ class LightState : public EntityBase, public Component { } /// The result of all the current_values_as_* methods have gamma correction applied. - void current_values_as_binary(bool *binary); + void current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void current_values_as_brightness(float *brightness); @@ -281,7 +285,7 @@ class LightState : public EntityBase, public Component { * return; * } */ - bool is_transformer_active(); + bool is_transformer_active() const { return this->is_transformer_active_; } protected: friend LightOutput; From 763a1d9371690543487037fa97a53d2bb2ea03a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:16 -0500 Subject: [PATCH 343/597] [select] Inline the trivial Select accessors (#18621) --- esphome/components/select/select.cpp | 17 ----------------- esphome/components/select/select.h | 14 ++++++++------ esphome/components/select/select_traits.cpp | 2 -- esphome/components/select/select_traits.h | 2 +- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 17c6c811dd..05a0ee1ed9 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -8,8 +8,6 @@ namespace esphome::select { static const char *const TAG = "select"; -void Select::publish_state(const std::string &state) { this->publish_state(state.c_str()); } - void Select::publish_state(const char *state) { auto index = this->index_of(state); if (index.has_value()) { @@ -34,21 +32,6 @@ void Select::publish_state(size_t index) { #endif } -StringRef Select::current_option() const { - return this->has_state() ? StringRef(this->option_at(this->active_index_)) : StringRef(); -} - -bool Select::has_option(const std::string &option) const { return this->index_of(option.c_str()).has_value(); } - -bool Select::has_option(const char *option) const { return this->index_of(option).has_value(); } - -bool Select::has_index(size_t index) const { return index < this->size(); } - -size_t Select::size() const { - const auto &options = traits.get_options(); - return options.size(); -} - optional Select::index_of(const char *option, size_t len) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 34d9248523..2294f34e62 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -33,27 +33,29 @@ class Select : public EntityBase { Select() = default; ~Select() = default; - void publish_state(const std::string &state); + void publish_state(const std::string &state) { this->publish_state(state.c_str()); } void publish_state(const char *state); void publish_state(size_t index); /// Return the currently selected option, or empty StringRef if no state. /// The returned StringRef points to string literals from codegen (static storage). /// Traits are set once at startup and valid for the lifetime of the program. - StringRef current_option() const; + StringRef current_option() const { + return this->has_state() ? StringRef(this->option_at(this->active_index_)) : StringRef(); + } /// Instantiate a SelectCall object to modify this select component's state. SelectCall make_call() { return SelectCall(this); } /// Return whether this select component contains the provided option. - bool has_option(const std::string &option) const; - bool has_option(const char *option) const; + bool has_option(const std::string &option) const { return this->index_of(option).has_value(); } + bool has_option(const char *option) const { return this->index_of(option).has_value(); } /// Return whether this select component contains the provided index offset. - bool has_index(size_t index) const; + bool has_index(size_t index) const { return index < this->size(); } /// Return the number of options in this select component. - size_t size() const; + size_t size() const { return this->traits.get_options().size(); } /// Find the (optional) index offset of the provided option value. optional index_of(const char *option, size_t len) const; diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index ff52c0d85b..67a5118646 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -11,6 +11,4 @@ void SelectTraits::set_options(const FixedVector &options) { } } -const FixedVector &SelectTraits::get_options() const { return this->options_; } - } // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index 78a83e5944..e1b261bc96 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -9,7 +9,7 @@ class SelectTraits { public: void set_options(const std::initializer_list &options); void set_options(const FixedVector &options); - const FixedVector &get_options() const; + const FixedVector &get_options() const { return this->options_; } protected: FixedVector options_; From c60062c418b3a2e2fb841557d611bd0f341ae551 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:28 -0500 Subject: [PATCH 344/597] [sensor] Inline the trivial ExponentialMovingAverageFilter setters (#18622) --- esphome/components/sensor/filter.cpp | 2 -- esphome/components/sensor/filter.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 0105580d26..dbd6f4d34b 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -164,8 +164,6 @@ optional ExponentialMovingAverageFilter::new_value(float value) { } return {}; } -void ExponentialMovingAverageFilter::set_send_every(uint16_t send_every) { this->send_every_ = send_every; } -void ExponentialMovingAverageFilter::set_alpha(float alpha) { this->alpha_ = alpha; } // ThrottleAverageFilter ThrottleAverageFilter::ThrottleAverageFilter(uint32_t time_period) : time_period_(time_period) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index b79bfa17d6..bc086e3805 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -239,8 +239,8 @@ class ExponentialMovingAverageFilter : public Filter { optional new_value(float value) override; - void set_send_every(uint16_t send_every); - void set_alpha(float alpha); + void set_send_every(uint16_t send_every) { this->send_every_ = send_every; } + void set_alpha(float alpha) { this->alpha_ = alpha; } protected: float accumulator_{NAN}; From efc0a94112f93d25b9806a332310a877faebe6b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:42 -0500 Subject: [PATCH 345/597] [text_sensor] Inline the trivial TextSensor forwarding overloads (#18623) --- esphome/components/text_sensor/text_sensor.cpp | 8 -------- esphome/components/text_sensor/text_sensor.h | 8 +++++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index d2483619a6..17c606d253 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -18,10 +18,6 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text LOG_ENTITY_ICON(tag, prefix, *obj); } -void TextSensor::publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } - -void TextSensor::publish_state(const char *state) { this->publish_state(state, strlen(state)); } - void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER if (this->filter_list_ == nullptr) { @@ -91,10 +87,6 @@ const std::string &TextSensor::get_raw_state() const { #endif return this->state; // No filters, raw == filtered } -void TextSensor::internal_send_state_to_frontend(const std::string &state) { - this->internal_send_state_to_frontend(state.data(), state.size()); -} - void TextSensor::internal_send_state_to_frontend(const char *state, size_t len) { // Only assign if changed to avoid heap allocation if (len != this->state.size() || memcmp(state, this->state.data(), len) != 0) { diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index aa48781f41..0e7364bf98 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -37,8 +37,8 @@ class TextSensor : public EntityBase { /// Returns the raw (pre-filter) state. const std::string &get_raw_state() const; - void publish_state(const std::string &state); - void publish_state(const char *state); + void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } + void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); #ifdef USE_TEXT_SENSOR_FILTER @@ -70,7 +70,9 @@ class TextSensor : public EntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void internal_send_state_to_frontend(const std::string &state); + void internal_send_state_to_frontend(const std::string &state) { + this->internal_send_state_to_frontend(state.data(), state.size()); + } void internal_send_state_to_frontend(const char *state, size_t len); protected: From 5c2286cc4a1ea1cd3392df32ec1dc27f4ca4a27b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:01:55 -0500 Subject: [PATCH 346/597] [climate] Inline the trivial visual override setters (#18624) --- esphome/components/climate/climate.cpp | 23 ----------------------- esphome/components/climate/climate.h | 21 ++++++++++++++++----- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index b41ca4a540..0f01443bd0 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -511,29 +511,6 @@ ClimateTraits Climate::get_traits() { return traits; } -#ifdef USE_CLIMATE_VISUAL_OVERRIDES -void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { - this->visual_min_temperature_override_ = visual_min_temperature_override; -} - -void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) { - this->visual_max_temperature_override_ = visual_max_temperature_override; -} - -void Climate::set_visual_temperature_step_override(float target, float current) { - this->visual_target_temperature_step_override_ = target; - this->visual_current_temperature_step_override_ = current; -} - -void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) { - this->visual_min_humidity_override_ = visual_min_humidity_override; -} - -void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { - this->visual_max_humidity_override_ = visual_max_humidity_override; -} -#endif - ClimateCall Climate::make_call() { return ClimateCall(this); } ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 04f653a2b0..a906897235 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -228,11 +228,22 @@ class Climate : public EntityBase { ClimateTraits get_traits(); #ifdef USE_CLIMATE_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float visual_min_temperature_override); - void set_visual_max_temperature_override(float visual_max_temperature_override); - void set_visual_temperature_step_override(float target, float current); - void set_visual_min_humidity_override(float visual_min_humidity_override); - void set_visual_max_humidity_override(float visual_max_humidity_override); + void set_visual_min_temperature_override(float visual_min_temperature_override) { + this->visual_min_temperature_override_ = visual_min_temperature_override; + } + void set_visual_max_temperature_override(float visual_max_temperature_override) { + this->visual_max_temperature_override_ = visual_max_temperature_override; + } + void set_visual_temperature_step_override(float target, float current) { + this->visual_target_temperature_step_override_ = target; + this->visual_current_temperature_step_override_ = current; + } + void set_visual_min_humidity_override(float visual_min_humidity_override) { + this->visual_min_humidity_override_ = visual_min_humidity_override; + } + void set_visual_max_humidity_override(float visual_max_humidity_override) { + this->visual_max_humidity_override_ = visual_max_humidity_override; + } #endif /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). From ce019f508d3a78a31e17bf71f98a70ef0d63419e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:04 -0500 Subject: [PATCH 347/597] [safe_mode] Inline the trivial set_safe_mode setters (#18626) --- esphome/components/safe_mode/button/safe_mode_button.cpp | 4 ---- esphome/components/safe_mode/button/safe_mode_button.h | 2 +- esphome/components/safe_mode/switch/safe_mode_switch.cpp | 4 ---- esphome/components/safe_mode/switch/safe_mode_switch.h | 2 +- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/safe_mode/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp index 04203854fb..982ecf8402 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ b/esphome/components/safe_mode/button/safe_mode_button.cpp @@ -7,10 +7,6 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode.button"; -void SafeModeButton::set_safe_mode(SafeModeComponent *safe_mode_component) { - this->safe_mode_component_ = safe_mode_component; -} - void SafeModeButton::press_action() { ESP_LOGI(TAG, "Restarting in safe mode"); this->safe_mode_component_->set_safe_mode_pending(true); diff --git a/esphome/components/safe_mode/button/safe_mode_button.h b/esphome/components/safe_mode/button/safe_mode_button.h index 6012bb2aeb..035bd77802 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.h +++ b/esphome/components/safe_mode/button/safe_mode_button.h @@ -9,7 +9,7 @@ namespace esphome::safe_mode { class SafeModeButton final : public button::Button, public Component { public: void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component); + void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } protected: SafeModeComponent *safe_mode_component_; diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.cpp b/esphome/components/safe_mode/switch/safe_mode_switch.cpp index f513465db0..b4b9735757 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.cpp +++ b/esphome/components/safe_mode/switch/safe_mode_switch.cpp @@ -7,10 +7,6 @@ namespace esphome::safe_mode { static const char *const TAG = "safe_mode.switch"; -void SafeModeSwitch::set_safe_mode(SafeModeComponent *safe_mode_component) { - this->safe_mode_component_ = safe_mode_component; -} - void SafeModeSwitch::write_state(bool state) { // Acknowledge this->publish_state(false); diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index cbd79cd520..cb48023f63 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -9,7 +9,7 @@ namespace esphome::safe_mode { class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component); + void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } protected: SafeModeComponent *safe_mode_component_; From dba3b287dd817b9ad68941b7fd1a8294bdc4a616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:14 -0500 Subject: [PATCH 348/597] [api] Inline the trivial APIServer accessors (#18627) --- esphome/components/api/api_server.cpp | 10 ---------- esphome/components/api/api_server.h | 10 +++++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ef5b43d7b1..2d5f9e4155 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -423,12 +423,6 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif -float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; } - -void APIServer::set_port(uint16_t port) { this->port_ = port; } - -void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } - #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { bool has_subscriber = false; @@ -553,10 +547,6 @@ const std::vector &APIServer::get_sta } #endif -uint16_t APIServer::get_port() const { return this->port_; } - -void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } - #ifdef USE_API_NOISE bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 248b83a0ff..a58e42534b 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -51,8 +51,8 @@ class APIServer final : public Component, public: APIServer(); void setup() override; - uint16_t get_port() const; - float get_setup_priority() const override; + uint16_t get_port() const { return this->port_; } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void loop() override; void dump_config() override; void on_shutdown() override; @@ -63,9 +63,9 @@ class APIServer final : public Component, #ifdef USE_CAMERA void on_camera_image(const std::shared_ptr &image) override; #endif - void set_port(uint16_t port); - void set_reboot_timeout(uint32_t reboot_timeout); - void set_batch_delay(uint16_t batch_delay); + void set_port(uint16_t port) { this->port_ = port; } + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } + void set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } From 0e915e9b8bf709acd8b63258cfb6d7bd27b2a85b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:02:58 -0500 Subject: [PATCH 349/597] [core] Inline the ESPTime::strftime std::string overload (#18628) --- esphome/core/time.cpp | 2 -- esphome/core/time.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index b6fc9b90ad..d1ba981e95 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -114,8 +114,6 @@ std::string ESPTime::strftime(const char *format) { return std::string(buf, len); } -std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } - // Helper to parse exactly N digits, returns false if not enough digits static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) { value = 0; diff --git a/esphome/core/time.h b/esphome/core/time.h index 0b67b7b3fc..f58cf20b4e 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -71,7 +71,7 @@ struct ESPTime { * @warning This method can return "ERROR" when the underlying strftime() call fails or when the * output exceeds STRFTIME_BUFFER_SIZE bytes. */ - std::string strftime(const std::string &format); + std::string strftime(const std::string &format) { return this->strftime(format.c_str()); } /// @copydoc strftime(const std::string &format) std::string strftime(const char *format); From 3ef5a8e6a4cca3f061712c39a8757c8e1a001eb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:07 -0500 Subject: [PATCH 350/597] [water_heater] Inline the trivial visual override setters (#18629) --- esphome/components/water_heater/water_heater.cpp | 12 ------------ esphome/components/water_heater/water_heater.h | 12 +++++++++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 9ee8faadee..9862253ad9 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -233,18 +233,6 @@ WaterHeaterTraits WaterHeater::get_traits() { return traits; } -#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES -void WaterHeater::set_visual_min_temperature_override(float min_temperature_override) { - this->visual_min_temperature_override_ = min_temperature_override; -} -void WaterHeater::set_visual_max_temperature_override(float max_temperature_override) { - this->visual_max_temperature_override_ = max_temperature_override; -} -void WaterHeater::set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { - this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; -} -#endif - // Water heater mode strings indexed by WaterHeaterMode enum (0-6): OFF, ECO, ELECTRIC, PERFORMANCE, HIGH_DEMAND, // HEAT_PUMP, GAS PROGMEM_STRING_TABLE(WaterHeaterModeStrings, "OFF", "ECO", "ELECTRIC", "PERFORMANCE", "HIGH_DEMAND", "HEAT_PUMP", "GAS", diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 995b815440..1255a68595 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -217,9 +217,15 @@ class WaterHeater : public EntityBase { virtual WaterHeaterCallInternal make_call() = 0; #ifdef USE_WATER_HEATER_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float min_temperature_override); - void set_visual_max_temperature_override(float max_temperature_override); - void set_visual_target_temperature_step_override(float visual_target_temperature_step_override); + void set_visual_min_temperature_override(float min_temperature_override) { + this->visual_min_temperature_override_ = min_temperature_override; + } + void set_visual_max_temperature_override(float max_temperature_override) { + this->visual_max_temperature_override_ = max_temperature_override; + } + void set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { + this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; + } #endif virtual void control(const WaterHeaterCall &call) = 0; From cb4e55e4449b08dac696d38dc4314216a8c9b332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:15 -0500 Subject: [PATCH 351/597] [cover] Inline the trivial Cover and CoverCall accessors (#18630) --- esphome/components/cover/cover.cpp | 7 ------- esphome/components/cover/cover.h | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index e98a555fe5..dc2db3bf32 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -135,10 +135,6 @@ CoverCall &CoverCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool CoverCall::get_stop() const { return this->stop_; } - -CoverCall Cover::make_call() { return {this}; } - void Cover::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); this->tilt = clamp(this->tilt, 0.0f, 1.0f); @@ -184,9 +180,6 @@ optional Cover::restore_state_() { return recovered; } -bool Cover::is_fully_open() const { return this->position == COVER_OPEN; } -bool Cover::is_fully_closed() const { return this->position == COVER_CLOSED; } - CoverCall CoverRestoreState::to_call(Cover *cover) { auto call = cover->make_call(); auto traits = cover->get_traits(); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 9a75e68487..8bf45cfb57 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -50,7 +50,7 @@ class CoverCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_tilt() const; const optional &get_toggle() const; @@ -123,7 +123,7 @@ class Cover : public EntityBase { float tilt{COVER_OPEN}; /// Construct a new cover call used to control the cover. - CoverCall make_call(); + CoverCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -139,9 +139,9 @@ class Cover : public EntityBase { virtual CoverTraits get_traits() = 0; /// Helper method to check if the cover is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == COVER_OPEN; } /// Helper method to check if the cover is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == COVER_CLOSED; } protected: friend CoverCall; From fedb3ac5c1999f03eb4f47f1b63c2d23b37ed47f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:38 -0500 Subject: [PATCH 352/597] [fan] Inline the trivial Fan call helpers (#18631) --- esphome/components/fan/fan.cpp | 5 ----- esphome/components/fan/fan.h | 8 ++++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 853bf94ffe..7dc0b5c6fe 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -153,11 +153,6 @@ void FanRestoreState::apply(Fan &fan) { fan.publish_state(); } -FanCall Fan::turn_on() { return this->make_call().set_state(true); } -FanCall Fan::turn_off() { return this->make_call().set_state(false); } -FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } -FanCall Fan::make_call() { return FanCall(*this); } - const char *Fan::find_preset_mode_(const char *preset_mode) { return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 3d731e6eb0..106e6e74cd 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -115,10 +115,10 @@ class Fan : public EntityBase { /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; - FanCall turn_on(); - FanCall turn_off(); - FanCall toggle(); - FanCall make_call(); + FanCall turn_on() { return this->make_call().set_state(true); } + FanCall turn_off() { return this->make_call().set_state(false); } + FanCall toggle() { return this->make_call().set_state(!this->state); } + FanCall make_call() { return FanCall(*this); } /// Register a callback that will be called each time the state changes. template void add_on_state_callback(F &&callback) { From 832a738588e2d308e981c71a6620e894a2ac356d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:03:51 -0500 Subject: [PATCH 353/597] [switch] Inline the trivial inverted accessors (#18632) --- esphome/components/switch/switch.cpp | 3 --- esphome/components/switch/switch.h | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index abc7338a62..101a0b9ffa 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -69,9 +69,6 @@ void Switch::publish_state(bool state) { } bool Switch::assumed_state() { return false; } -void Switch::set_inverted(bool inverted) { this->inverted_ = inverted; } -bool Switch::is_inverted() const { return this->inverted_; } - void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj) { if (obj != nullptr) { // Prepare restore mode string diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index b7761cba0a..0564c3efd2 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -87,7 +87,7 @@ class Switch : public EntityBase { * * @param inverted Whether to invert this switch. */ - void set_inverted(bool inverted); + void set_inverted(bool inverted) { this->inverted_ = inverted; } /** Set callback for state changes. * @@ -117,7 +117,7 @@ class Switch : public EntityBase { */ virtual bool assumed_state(); - bool is_inverted() const; + bool is_inverted() const { return this->inverted_; } void set_restore_mode(SwitchRestoreMode restore_mode) { this->restore_mode = restore_mode; } From ad1a4fca3653f4cc98da63abe7b66f434f7ee66c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:08 -0500 Subject: [PATCH 354/597] [version] Inline the trivial VersionTextSensor setters (#18633) --- esphome/components/version/version_text_sensor.cpp | 2 -- esphome/components/version/version_text_sensor.h | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 34c7aae6bc..15e6b0d088 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -48,8 +48,6 @@ void VersionTextSensor::setup() { version_str[sizeof(version_str) - 1] = '\0'; this->publish_state(version_str); } -void VersionTextSensor::set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } -void VersionTextSensor::set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void VersionTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Version Text Sensor", this); } } // namespace esphome::version diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index d2ca0ba6f6..96f72ad035 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,8 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: - void set_hide_hash(bool hide_hash); - void set_hide_timestamp(bool hide_timestamp); + void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } + void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; void dump_config() override; From ecb007da70a94f6605d01f8d775c27a639ae5e02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:23 -0500 Subject: [PATCH 355/597] [deep_sleep] Inline the trivial DeepSleepComponent setters (#18634) --- esphome/components/deep_sleep/deep_sleep_component.cpp | 8 -------- esphome/components/deep_sleep/deep_sleep_component.h | 10 +++++----- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 6 ------ 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index e7ce70b60c..9a3e537e05 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -43,10 +43,6 @@ void DeepSleepComponent::loop() { this->begin_sleep(); } -void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } - -void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } - void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { this->next_enter_deep_sleep_ = true; @@ -76,8 +72,4 @@ void DeepSleepComponent::begin_sleep(bool manual) { float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } -void DeepSleepComponent::prevent_deep_sleep() { this->prevent_ = true; } - -void DeepSleepComponent::allow_deep_sleep() { this->prevent_ = false; } - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index a620d52a02..208f88d707 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -132,7 +132,7 @@ template class PreventDeepSleepAction; class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. - void set_sleep_duration(uint32_t time_ms); + void set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } #if defined(USE_ESP32) /** Set the pin to wake up to on the ESP32 once it's in deep sleep mode. * Use the inverted property to set the wakeup level. @@ -157,7 +157,7 @@ class DeepSleepComponent final : public Component { #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) - void set_touch_wakeup(bool touch_wakeup); + void set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif // Set the duration in ms for how long the code should run before entering @@ -166,7 +166,7 @@ class DeepSleepComponent final : public Component { #endif // USE_ESP32 /// Set a duration in ms for how long the code should run before entering deep sleep mode. - void set_run_duration(uint32_t time_ms); + void set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } void setup() override; void dump_config() override; @@ -176,8 +176,8 @@ class DeepSleepComponent final : public Component { /// Helper to enter deep sleep mode void begin_sleep(bool manual = false); - void prevent_deep_sleep(); - void allow_deep_sleep(); + void prevent_deep_sleep() { this->prevent_ = true; } + void allow_deep_sleep() { this->prevent_ = false; } protected: // Returns nullopt if no run duration is set. Otherwise, returns the run diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index f64e1f37e1..3fa1a1f1ed 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -74,12 +74,6 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) { void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; } #endif -#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } -#endif - void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { wakeup_cause_to_run_duration_ = wakeup_cause_to_run_duration; } From 5a9f06e584ac8e771f9aaca53ae85574f5d7776b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:41 -0500 Subject: [PATCH 356/597] [thermostat] Inline the trivial ThermostatClimate setters and getters (#18635) --- .../thermostat/thermostat_climate.cpp | 95 -------------- .../thermostat/thermostat_climate.h | 116 +++++++++++------- 2 files changed, 74 insertions(+), 137 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 2390a96337..c10eb5b9f5 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -76,11 +76,6 @@ void ThermostatClimate::loop() { } } -float ThermostatClimate::cool_deadband() { return this->cooling_deadband_; } -float ThermostatClimate::cool_overrun() { return this->cooling_overrun_; } -float ThermostatClimate::heat_deadband() { return this->heating_deadband_; } -float ThermostatClimate::heat_overrun() { return this->heating_overrun_; } - void ThermostatClimate::refresh() { this->switch_to_mode_(this->mode, false); this->switch_to_action_(this->compute_action_(), false); @@ -121,8 +116,6 @@ bool ThermostatClimate::fan_mode_change_delayed() { climate::ClimateAction ThermostatClimate::delayed_climate_action() { return this->compute_action_(true); } -climate::ClimateFanMode ThermostatClimate::locked_fan_mode() { return this->prev_fan_mode_; } - bool ThermostatClimate::hysteresis_valid() { if ((this->supports_cool_ || (this->supports_fan_only_ && this->supports_fan_only_cooling_)) && (std::isnan(this->cooling_deadband_) || std::isnan(this->cooling_overrun_))) @@ -1286,10 +1279,6 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_preset_config(std::initializer_list presets) { - this->preset_config_ = presets; -} - void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { this->custom_preset_config_ = presets; // Populate Climate base class custom presets vector @@ -1317,19 +1306,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } -void ThermostatClimate::set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { - this->on_boot_restore_from_ = on_boot_restore_from; -} -void ThermostatClimate::set_set_point_minimum_differential(float differential) { - this->set_point_minimum_differential_ = differential; -} -void ThermostatClimate::set_cool_deadband(float deadband) { this->cooling_deadband_ = deadband; } -void ThermostatClimate::set_cool_overrun(float overrun) { this->cooling_overrun_ = overrun; } -void ThermostatClimate::set_heat_deadband(float deadband) { this->heating_deadband_ = deadband; } -void ThermostatClimate::set_heat_overrun(float overrun) { this->heating_overrun_ = overrun; } -void ThermostatClimate::set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } -void ThermostatClimate::set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); @@ -1389,80 +1365,9 @@ void ThermostatClimate::set_heating_minimum_run_time_in_sec(uint32_t time) { void ThermostatClimate::set_idle_minimum_time_in_sec(uint32_t time) { this->set_timer_duration_in_sec_(thermostat::THERMOSTAT_TIMER_IDLE_ON, time); } -void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void ThermostatClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { - this->humidity_sensor_ = humidity_sensor; -} void ThermostatClimate::set_humidity_hysteresis(float humidity_hysteresis) { this->humidity_hysteresis_ = std::clamp(humidity_hysteresis, 0.0f, 100.0f); } -void ThermostatClimate::set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } -void ThermostatClimate::set_supports_heat_cool(bool supports_heat_cool) { - this->supports_heat_cool_ = supports_heat_cool; -} -void ThermostatClimate::set_supports_auto(bool supports_auto) { this->supports_auto_ = supports_auto; } -void ThermostatClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void ThermostatClimate::set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } -void ThermostatClimate::set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } -void ThermostatClimate::set_supports_fan_only_action_uses_fan_mode_timer( - bool supports_fan_only_action_uses_fan_mode_timer) { - this->supports_fan_only_action_uses_fan_mode_timer_ = supports_fan_only_action_uses_fan_mode_timer; -} -void ThermostatClimate::set_supports_fan_only_cooling(bool supports_fan_only_cooling) { - this->supports_fan_only_cooling_ = supports_fan_only_cooling; -} -void ThermostatClimate::set_supports_fan_with_cooling(bool supports_fan_with_cooling) { - this->supports_fan_with_cooling_ = supports_fan_with_cooling; -} -void ThermostatClimate::set_supports_fan_with_heating(bool supports_fan_with_heating) { - this->supports_fan_with_heating_ = supports_fan_with_heating; -} -void ThermostatClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } -void ThermostatClimate::set_supports_fan_mode_on(bool supports_fan_mode_on) { - this->supports_fan_mode_on_ = supports_fan_mode_on; -} -void ThermostatClimate::set_supports_fan_mode_off(bool supports_fan_mode_off) { - this->supports_fan_mode_off_ = supports_fan_mode_off; -} -void ThermostatClimate::set_supports_fan_mode_auto(bool supports_fan_mode_auto) { - this->supports_fan_mode_auto_ = supports_fan_mode_auto; -} -void ThermostatClimate::set_supports_fan_mode_low(bool supports_fan_mode_low) { - this->supports_fan_mode_low_ = supports_fan_mode_low; -} -void ThermostatClimate::set_supports_fan_mode_medium(bool supports_fan_mode_medium) { - this->supports_fan_mode_medium_ = supports_fan_mode_medium; -} -void ThermostatClimate::set_supports_fan_mode_high(bool supports_fan_mode_high) { - this->supports_fan_mode_high_ = supports_fan_mode_high; -} -void ThermostatClimate::set_supports_fan_mode_middle(bool supports_fan_mode_middle) { - this->supports_fan_mode_middle_ = supports_fan_mode_middle; -} -void ThermostatClimate::set_supports_fan_mode_focus(bool supports_fan_mode_focus) { - this->supports_fan_mode_focus_ = supports_fan_mode_focus; -} -void ThermostatClimate::set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse) { - this->supports_fan_mode_diffuse_ = supports_fan_mode_diffuse; -} -void ThermostatClimate::set_supports_fan_mode_quiet(bool supports_fan_mode_quiet) { - this->supports_fan_mode_quiet_ = supports_fan_mode_quiet; -} -void ThermostatClimate::set_supports_swing_mode_both(bool supports_swing_mode_both) { - this->supports_swing_mode_both_ = supports_swing_mode_both; -} -void ThermostatClimate::set_supports_swing_mode_off(bool supports_swing_mode_off) { - this->supports_swing_mode_off_ = supports_swing_mode_off; -} -void ThermostatClimate::set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal) { - this->supports_swing_mode_horizontal_ = supports_swing_mode_horizontal; -} -void ThermostatClimate::set_supports_swing_mode_vertical(bool supports_swing_mode_vertical) { - this->supports_swing_mode_vertical_ = supports_swing_mode_vertical; -} -void ThermostatClimate::set_supports_two_points(bool supports_two_points) { - this->supports_two_points_ = supports_two_points; -} void ThermostatClimate::set_supports_dehumidification(bool supports_dehumidification) { this->supports_dehumidification_ = supports_dehumidification; if (supports_dehumidification) { diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index f30659a8a6..4dc2a74d8e 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -93,14 +93,16 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_default_preset(const char *custom_preset); void set_default_preset(climate::ClimatePreset preset); - void set_on_boot_restore_from(OnBootRestoreFrom on_boot_restore_from); - void set_set_point_minimum_differential(float differential); - void set_cool_deadband(float deadband); - void set_cool_overrun(float overrun); - void set_heat_deadband(float deadband); - void set_heat_overrun(float overrun); - void set_supplemental_cool_delta(float delta); - void set_supplemental_heat_delta(float delta); + void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { + this->on_boot_restore_from_ = on_boot_restore_from; + } + void set_set_point_minimum_differential(float differential) { this->set_point_minimum_differential_ = differential; } + void set_cool_deadband(float deadband) { this->cooling_deadband_ = deadband; } + void set_cool_overrun(float overrun) { this->cooling_overrun_ = overrun; } + void set_heat_deadband(float deadband) { this->heating_deadband_ = deadband; } + void set_heat_overrun(float overrun) { this->heating_overrun_ = overrun; } + void set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } + void set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } void set_cooling_maximum_run_time_in_sec(uint32_t time); void set_heating_maximum_run_time_in_sec(uint32_t time); void set_cooling_minimum_off_time_in_sec(uint32_t time); @@ -111,39 +113,69 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_heating_minimum_off_time_in_sec(uint32_t time); void set_heating_minimum_run_time_in_sec(uint32_t time); void set_idle_minimum_time_in_sec(uint32_t time); - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void set_humidity_hysteresis(float humidity_hysteresis); - void set_use_startup_delay(bool use_startup_delay); - void set_supports_auto(bool supports_auto); - void set_supports_heat_cool(bool supports_heat_cool); - void set_supports_cool(bool supports_cool); - void set_supports_dry(bool supports_dry); - void set_supports_fan_only(bool supports_fan_only); - void set_supports_fan_only_action_uses_fan_mode_timer(bool fan_only_action_uses_fan_mode_timer); - void set_supports_fan_only_cooling(bool supports_fan_only_cooling); - void set_supports_fan_with_cooling(bool supports_fan_with_cooling); - void set_supports_fan_with_heating(bool supports_fan_with_heating); - void set_supports_heat(bool supports_heat); - void set_supports_fan_mode_on(bool supports_fan_mode_on); - void set_supports_fan_mode_off(bool supports_fan_mode_off); - void set_supports_fan_mode_auto(bool supports_fan_mode_auto); - void set_supports_fan_mode_low(bool supports_fan_mode_low); - void set_supports_fan_mode_medium(bool supports_fan_mode_medium); - void set_supports_fan_mode_high(bool supports_fan_mode_high); - void set_supports_fan_mode_middle(bool supports_fan_mode_middle); - void set_supports_fan_mode_focus(bool supports_fan_mode_focus); - void set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse); - void set_supports_fan_mode_quiet(bool supports_fan_mode_quiet); - void set_supports_swing_mode_both(bool supports_swing_mode_both); - void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal); - void set_supports_swing_mode_off(bool supports_swing_mode_off); - void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical); + void set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } + void set_supports_auto(bool supports_auto) { this->supports_auto_ = supports_auto; } + void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } + void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } + void set_supports_fan_only_action_uses_fan_mode_timer(bool supports_fan_only_action_uses_fan_mode_timer) { + this->supports_fan_only_action_uses_fan_mode_timer_ = supports_fan_only_action_uses_fan_mode_timer; + } + void set_supports_fan_only_cooling(bool supports_fan_only_cooling) { + this->supports_fan_only_cooling_ = supports_fan_only_cooling; + } + void set_supports_fan_with_cooling(bool supports_fan_with_cooling) { + this->supports_fan_with_cooling_ = supports_fan_with_cooling; + } + void set_supports_fan_with_heating(bool supports_fan_with_heating) { + this->supports_fan_with_heating_ = supports_fan_with_heating; + } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_fan_mode_on(bool supports_fan_mode_on) { this->supports_fan_mode_on_ = supports_fan_mode_on; } + void set_supports_fan_mode_off(bool supports_fan_mode_off) { this->supports_fan_mode_off_ = supports_fan_mode_off; } + void set_supports_fan_mode_auto(bool supports_fan_mode_auto) { + this->supports_fan_mode_auto_ = supports_fan_mode_auto; + } + void set_supports_fan_mode_low(bool supports_fan_mode_low) { this->supports_fan_mode_low_ = supports_fan_mode_low; } + void set_supports_fan_mode_medium(bool supports_fan_mode_medium) { + this->supports_fan_mode_medium_ = supports_fan_mode_medium; + } + void set_supports_fan_mode_high(bool supports_fan_mode_high) { + this->supports_fan_mode_high_ = supports_fan_mode_high; + } + void set_supports_fan_mode_middle(bool supports_fan_mode_middle) { + this->supports_fan_mode_middle_ = supports_fan_mode_middle; + } + void set_supports_fan_mode_focus(bool supports_fan_mode_focus) { + this->supports_fan_mode_focus_ = supports_fan_mode_focus; + } + void set_supports_fan_mode_diffuse(bool supports_fan_mode_diffuse) { + this->supports_fan_mode_diffuse_ = supports_fan_mode_diffuse; + } + void set_supports_fan_mode_quiet(bool supports_fan_mode_quiet) { + this->supports_fan_mode_quiet_ = supports_fan_mode_quiet; + } + void set_supports_swing_mode_both(bool supports_swing_mode_both) { + this->supports_swing_mode_both_ = supports_swing_mode_both; + } + void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal) { + this->supports_swing_mode_horizontal_ = supports_swing_mode_horizontal; + } + void set_supports_swing_mode_off(bool supports_swing_mode_off) { + this->supports_swing_mode_off_ = supports_swing_mode_off; + } + void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical) { + this->supports_swing_mode_vertical_ = supports_swing_mode_vertical; + } void set_supports_dehumidification(bool supports_dehumidification); void set_supports_humidification(bool supports_humidification); - void set_supports_two_points(bool supports_two_points); + void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets); + void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } void set_custom_preset_config(std::initializer_list presets); Trigger<> *get_cool_action_trigger(); @@ -181,10 +213,10 @@ class ThermostatClimate final : public climate::Climate, public Component { Trigger<> *get_humidity_control_humidify_action_trigger(); Trigger<> *get_humidity_control_off_action_trigger(); /// Get current hysteresis values - float cool_deadband(); - float cool_overrun(); - float heat_deadband(); - float heat_overrun(); + float cool_deadband() { return this->cooling_deadband_; } + float cool_overrun() { return this->cooling_overrun_; } + float heat_deadband() { return this->heating_deadband_; } + float heat_overrun() { return this->heating_overrun_; } /// Call triggers based on updated climate states (modes/actions) void refresh(); /// Returns true if a climate action/fan mode transition is being delayed @@ -193,7 +225,7 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Returns the climate action that is being delayed (check climate_action_change_delayed(), first!) climate::ClimateAction delayed_climate_action(); /// Returns the fan mode that is locked in (check fan_mode_change_delayed(), first!) - climate::ClimateFanMode locked_fan_mode(); + climate::ClimateFanMode locked_fan_mode() { return this->prev_fan_mode_; } /// Set point and hysteresis validation bool hysteresis_valid(); // returns true if valid bool humidity_hysteresis_valid(); // returns true if valid From 78240c9a46f63fb6e7778f2b2e5720765e7a8bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:04:57 -0500 Subject: [PATCH 357/597] [sprinkler] Inline the trivial Sprinkler accessors (#18636) --- esphome/components/sprinkler/sprinkler.cpp | 43 --------------------- esphome/components/sprinkler/sprinkler.h | 44 +++++++++++++--------- 2 files changed, 27 insertions(+), 60 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 336123a472..2edceb76a5 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -211,8 +211,6 @@ uint32_t SprinklerValveOperator::time_remaining() { return 0; // run completed } -SprinklerState SprinklerValveOperator::state() { return this->state_; } - switch_::Switch *SprinklerValveOperator::pump_switch() { if ((this->controller_ == nullptr) || (this->valve_ == nullptr)) { return nullptr; @@ -288,11 +286,8 @@ SprinklerValveRunRequest::SprinklerValveRunRequest(size_t valve_number, uint32_t SprinklerValveOperator *valve_op) : valve_number_(valve_number), run_duration_(run_duration), valve_op_(valve_op) {} -bool SprinklerValveRunRequest::has_request() { return this->has_valve_; } bool SprinklerValveRunRequest::has_valve_operator() { return !(this->valve_op_ == nullptr); } -void SprinklerValveRunRequest::set_request_from(SprinklerValveRunRequestOrigin origin) { this->origin_ = origin; } - void SprinklerValveRunRequest::set_run_duration(uint32_t run_duration) { this->run_duration_ = run_duration; } void SprinklerValveRunRequest::set_valve(size_t valve_number) { @@ -317,8 +312,6 @@ void SprinklerValveRunRequest::reset() { uint32_t SprinklerValveRunRequest::run_duration() { return this->run_duration_; } -size_t SprinklerValveRunRequest::valve() { return this->valve_number_; } - optional SprinklerValveRunRequest::valve_as_opt() { if (this->has_valve_) { return this->valve_number_; @@ -328,8 +321,6 @@ optional SprinklerValveRunRequest::valve_as_opt() { SprinklerValveOperator *SprinklerValveRunRequest::valve_operator() { return this->valve_op_; } -SprinklerValveRunRequestOrigin SprinklerValveRunRequest::request_is_from() { return this->origin_; } - Sprinkler::Sprinkler() : Sprinkler("") {} Sprinkler::Sprinkler(const char *name) : name_(name) { // The `name` is stored for dump_config logging @@ -414,18 +405,6 @@ void Sprinkler::set_controller_main_switch(SprinklerControllerSwitch *controller this->sprinkler_turn_on_automation_->add_actions({sprinkler_resumeorstart_action_.get()}); } -void Sprinkler::set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch) { - this->auto_adv_sw_ = auto_adv_switch; -} - -void Sprinkler::set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch) { - this->queue_enable_sw_ = queue_enable_switch; -} - -void Sprinkler::set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch) { - this->reverse_sw_ = reverse_switch; -} - void Sprinkler::set_controller_standby_switch(SprinklerControllerSwitch *standby_switch) { this->standby_sw_ = standby_switch; @@ -434,14 +413,6 @@ void Sprinkler::set_controller_standby_switch(SprinklerControllerSwitch *standby this->sprinkler_standby_turn_on_automation_->add_actions({sprinkler_standby_shutdown_action_.get()}); } -void Sprinkler::set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number) { - this->multiplier_number_ = multiplier_number; -} - -void Sprinkler::set_controller_repeat_number(SprinklerControllerNumber *repeat_number) { - this->repeat_number_ = repeat_number; -} - void Sprinkler::configure_valve_switch(size_t valve_number, switch_::Switch *valve_switch, uint32_t run_duration) { if (this->is_a_valid_valve(valve_number)) { this->valve_[valve_number].valve_switch = valve_switch; @@ -498,10 +469,6 @@ void Sprinkler::set_multiplier(const optional multiplier) { call.perform(); } -void Sprinkler::set_next_prev_ignore_disabled_valves(bool ignore_disabled) { - this->next_prev_ignore_disabled_ = ignore_disabled; -} - void Sprinkler::set_pump_start_delay(uint32_t start_delay) { this->start_delay_is_valve_delay_ = false; this->start_delay_ = start_delay; @@ -522,10 +489,6 @@ void Sprinkler::set_valve_stop_delay(uint32_t stop_delay) { this->stop_delay_ = stop_delay; } -void Sprinkler::set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay) { - this->pump_switch_off_during_valve_open_delay_ = pump_switch_off_during_valve_open_delay; -} - void Sprinkler::set_valve_open_delay(const uint32_t valve_open_delay) { if (valve_open_delay > 0) { this->valve_overlap_ = false; @@ -945,8 +908,6 @@ optional Sprinkler::active_valve() { return this->active_req_.valve_as_opt(); } -optional Sprinkler::paused_valve() { return this->paused_valve_; } - optional Sprinkler::queued_valve() { if (!this->queued_valves_.empty()) { return this->queued_valves_.back().valve_number; @@ -954,10 +915,6 @@ optional Sprinkler::queued_valve() { return nullopt; } -optional Sprinkler::manual_valve() { return this->manual_valve_; } - -size_t Sprinkler::number_of_valves() { return this->valve_.size(); } - bool Sprinkler::is_a_valid_valve(const size_t valve_number) { return (valve_number < this->number_of_valves()); } bool Sprinkler::pump_in_use(switch_::Switch *pump_switch) { diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index bd610f7ad3..2499a0a591 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -124,9 +124,9 @@ class SprinklerValveOperator { void set_stop_delay(uint32_t stop_delay, bool stop_delay_is_valve_delay); void start(); void stop(); - uint32_t run_duration(); // returns the desired run duration in seconds - uint32_t time_remaining(); // returns seconds remaining (does not include stop_delay_) - SprinklerState state(); // returns the valve's state/status + uint32_t run_duration(); // returns the desired run duration in seconds + uint32_t time_remaining(); // returns seconds remaining (does not include stop_delay_) + SprinklerState state() { return this->state_; } switch_::Switch *pump_switch(); // returns this SprinklerValveOperator's pump switch protected: @@ -152,18 +152,18 @@ class SprinklerValveRunRequest { public: SprinklerValveRunRequest(); SprinklerValveRunRequest(size_t valve_number, uint32_t run_duration, SprinklerValveOperator *valve_op); - bool has_request(); + bool has_request() { return this->has_valve_; } bool has_valve_operator(); - void set_request_from(SprinklerValveRunRequestOrigin origin); + void set_request_from(SprinklerValveRunRequestOrigin origin) { this->origin_ = origin; } void set_run_duration(uint32_t run_duration); void set_valve(size_t valve_number); void set_valve_operator(SprinklerValveOperator *valve_op); void reset(); uint32_t run_duration(); - size_t valve(); + size_t valve() { return this->valve_number_; } optional valve_as_opt(); SprinklerValveOperator *valve_operator(); - SprinklerValveRunRequestOrigin request_is_from(); + SprinklerValveRunRequestOrigin request_is_from() { return this->origin_; } protected: bool has_valve_{false}; @@ -189,14 +189,20 @@ class Sprinkler final : public Component { /// configure important controller switches void set_controller_main_switch(SprinklerControllerSwitch *controller_switch); - void set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch); - void set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch); - void set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch); + void set_controller_auto_adv_switch(SprinklerControllerSwitch *auto_adv_switch) { + this->auto_adv_sw_ = auto_adv_switch; + } + void set_controller_queue_enable_switch(SprinklerControllerSwitch *queue_enable_switch) { + this->queue_enable_sw_ = queue_enable_switch; + } + void set_controller_reverse_switch(SprinklerControllerSwitch *reverse_switch) { this->reverse_sw_ = reverse_switch; } void set_controller_standby_switch(SprinklerControllerSwitch *standby_switch); /// configure important controller number components - void set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number); - void set_controller_repeat_number(SprinklerControllerNumber *repeat_number); + void set_controller_multiplier_number(SprinklerControllerNumber *multiplier_number) { + this->multiplier_number_ = multiplier_number; + } + void set_controller_repeat_number(SprinklerControllerNumber *repeat_number) { this->repeat_number_ = repeat_number; } /// configure a valve's switch object and run duration. run_duration is time in seconds. void configure_valve_switch(size_t valve_number, switch_::Switch *valve_switch, uint32_t run_duration); @@ -214,7 +220,9 @@ class Sprinkler final : public Component { void set_multiplier(optional multiplier); /// enable/disable skipping of disabled valves by the next and previous actions - void set_next_prev_ignore_disabled_valves(bool ignore_disabled); + void set_next_prev_ignore_disabled_valves(bool ignore_disabled) { + this->next_prev_ignore_disabled_ = ignore_disabled; + } /// set how long the pump should start after the valve (when the pump is starting) void set_pump_start_delay(uint32_t start_delay); @@ -230,7 +238,9 @@ class Sprinkler final : public Component { /// if pump_switch_off_during_valve_open_delay is true, the controller will switch off the pump during the /// valve_open_delay interval - void set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay); + void set_pump_switch_off_during_valve_open_delay(bool pump_switch_off_during_valve_open_delay) { + this->pump_switch_off_during_valve_open_delay_ = pump_switch_off_during_valve_open_delay; + } /// set how long the controller should wait to open/switch on the valve after it becomes active void set_valve_open_delay(uint32_t valve_open_delay); @@ -335,17 +345,17 @@ class Sprinkler final : public Component { optional active_valve(); /// returns the number of the valve that is paused, if any. check with 'has_value()' - optional paused_valve(); + optional paused_valve() { return this->paused_valve_; } /// returns the number of the next valve in the queue, if any. check with 'has_value()' optional queued_valve(); /// returns the number of the valve that is manually selected, if any. check with 'has_value()' /// this is set by next_valve() and previous_valve() when manual_selection_delay_ > 0 - optional manual_valve(); + optional manual_valve() { return this->manual_valve_; } /// returns the number of valves the controller is configured with - size_t number_of_valves(); + size_t number_of_valves() { return this->valve_.size(); } /// returns true if valve number is valid bool is_a_valid_valve(size_t valve_number); From 47156c9a5b5c517df04bb4c943a63a407435205a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:05:39 -0500 Subject: [PATCH 358/597] [mqtt] Inline the trivial MQTT client, component and sensor accessors (#18637) --- esphome/components/mqtt/mqtt_client.cpp | 8 -------- esphome/components/mqtt/mqtt_client.h | 12 ++++++------ esphome/components/mqtt/mqtt_component.cpp | 4 ---- esphome/components/mqtt/mqtt_component.h | 4 ++-- esphome/components/mqtt/mqtt_sensor.cpp | 2 -- esphome/components/mqtt/mqtt_sensor.h | 4 ++-- 6 files changed, 10 insertions(+), 24 deletions(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index ab665e2579..1127c36dc6 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -668,9 +668,7 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // Setters void MQTTClientComponent::disable_log_message() { this->log_message_.topic = ""; } bool MQTTClientComponent::is_log_message_enabled() const { return !this->log_message_.topic.empty(); } -void MQTTClientComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void MQTTClientComponent::register_mqtt_component(MQTTComponent *component) { this->children_.push_back(component); } -void MQTTClientComponent::set_log_level(int level) { this->log_level_ = level; } void MQTTClientComponent::set_keep_alive(uint16_t keep_alive_s) { this->mqtt_backend_.set_keep_alive(keep_alive_s); } void MQTTClientComponent::set_log_message_template(MQTTMessage &&message) { this->log_message_ = std::move(message); } const MQTTDiscoveryInfo &MQTTClientComponent::get_discovery_info() const { return this->discovery_info_; } @@ -683,10 +681,6 @@ void MQTTClientComponent::set_topic_prefix(const std::string &topic_prefix, cons } } const std::string &MQTTClientComponent::get_topic_prefix() const { return this->topic_prefix_; } -void MQTTClientComponent::set_publish_nan_as_none(bool publish_nan_as_none) { - this->publish_nan_as_none_ = publish_nan_as_none; -} -bool MQTTClientComponent::is_publish_nan_as_none() const { return this->publish_nan_as_none_; } void MQTTClientComponent::disable_birth_message() { this->birth_message_.topic = ""; this->recalculate_availability_(); @@ -766,8 +760,6 @@ MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines- // MQTTMessageTrigger MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::set_qos(uint8_t qos) { this->qos_ = qos; } -void MQTTMessageTrigger::set_payload(const std::string &payload) { this->payload_ = payload; } void MQTTMessageTrigger::setup() { global_mqtt_client->subscribe( this->topic_, diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index f741be561c..fe0966e725 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -159,7 +159,7 @@ class MQTTClientComponent final : public Component { /// Manually set the topic used for logging. void set_log_message_template(MQTTMessage &&message); - void set_log_level(int level); + void set_log_level(int level) { this->log_level_ = level; } /// Get the topic used for logging. Defaults to "/debug" and the value is cached for speed. void disable_log_message(); bool is_log_message_enabled() const; @@ -241,7 +241,7 @@ class MQTTClientComponent final : public Component { void check_connected(); - void set_reboot_timeout(uint32_t reboot_timeout); + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void register_mqtt_component(MQTTComponent *component); @@ -262,8 +262,8 @@ class MQTTClientComponent final : public Component { void set_on_disconnect(mqtt_on_disconnect_callback_t &&callback); // Publish None state instead of NaN for Home Assistant - void set_publish_nan_as_none(bool publish_nan_as_none); - bool is_publish_nan_as_none() const; + void set_publish_nan_as_none(bool publish_nan_as_none) { this->publish_nan_as_none_ = publish_nan_as_none; } + bool is_publish_nan_as_none() const { return this->publish_nan_as_none_; } void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; } @@ -344,8 +344,8 @@ class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); - void set_qos(uint8_t qos); - void set_payload(const std::string &payload); + void set_qos(uint8_t qos) { this->qos_ = qos; } + void set_payload(const std::string &payload) { this->payload_ = payload; } void setup() override; void dump_config() override; float get_setup_priority() const override; diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 3bbc1cdfa3..18a759725f 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -340,10 +340,6 @@ bool MQTTComponent::send_discovery_() { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } -uint8_t MQTTComponent::get_qos() const { return this->qos_; } - -bool MQTTComponent::get_retain() const { return this->retain_; } - bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 7983e04870..b4ae624404 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -108,11 +108,11 @@ class MQTTComponent : public Component { /// Set QOS for state messages. void set_qos(uint8_t qos); - uint8_t get_qos() const; + uint8_t get_qos() const { return this->qos_; } /// Set whether state message should be retained. void set_retain(bool retain); - bool get_retain() const; + bool get_retain() const { return this->retain_; } /// Disable discovery. Sets friendly name to "". void disable_discovery(); diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index c66465dd16..1c0625d1c9 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -39,8 +39,6 @@ uint32_t MQTTSensorComponent::get_expire_after() const { return *this->expire_after_; return 0; } -void MQTTSensorComponent::set_expire_after(uint32_t expire_after) { this->expire_after_ = expire_after; } -void MQTTSensorComponent::disable_expire_after() { this->expire_after_ = 0; } void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index 1d5ee8095c..a56963d9c1 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -22,9 +22,9 @@ class MQTTSensorComponent final : public mqtt::MQTTComponent { explicit MQTTSensorComponent(sensor::Sensor *sensor); /// Setup an expiry, 0 disables it - void set_expire_after(uint32_t expire_after); + void set_expire_after(uint32_t expire_after) { this->expire_after_ = expire_after; } /// Disable Home Assistant value expiry. - void disable_expire_after(); + void disable_expire_after() { this->expire_after_ = 0; } void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; From a30238aab6de17a67d1624291db47e325935ac79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:05:51 -0500 Subject: [PATCH 359/597] [wireguard] Inline the trivial Wireguard setters (#18638) --- esphome/components/wireguard/wireguard.cpp | 21 --------------------- esphome/components/wireguard/wireguard.h | 18 +++++++++--------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index b4641894db..2f07344d3b 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -178,25 +178,6 @@ time_t Wireguard::get_latest_handshake() const { return result; } -void Wireguard::set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } -void Wireguard::set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } -void Wireguard::set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } - -#ifdef USE_BINARY_SENSOR -void Wireguard::set_status_sensor(binary_sensor::BinarySensor *sensor) { this->status_sensor_ = sensor; } -void Wireguard::set_enabled_sensor(binary_sensor::BinarySensor *sensor) { this->enabled_sensor_ = sensor; } -#endif - -#ifdef USE_SENSOR -void Wireguard::set_handshake_sensor(sensor::Sensor *sensor) { this->handshake_sensor_ = sensor; } -#endif - -#ifdef USE_TEXT_SENSOR -void Wireguard::set_address_sensor(text_sensor::TextSensor *sensor) { this->address_sensor_ = sensor; } -#endif - -void Wireguard::disable_auto_proceed() { this->proceed_allowed_ = false; } - void Wireguard::enable() { this->enabled_ = true; ESP_LOGI(TAG, "Enabled"); @@ -218,8 +199,6 @@ void Wireguard::publish_enabled_state() { #endif } -bool Wireguard::is_enabled() { return this->enabled_; } - void Wireguard::start_connection_() { if (!this->enabled_) { ESP_LOGV(TAG, "Disabled, cannot start connection"); diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index 1fda802415..c9c2feb7ae 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -63,25 +63,25 @@ class Wireguard final : public PollingComponent { /// Prevent accidental use of std::string which would dangle void set_allowed_ips(std::initializer_list> ips) = delete; - void set_keepalive(uint16_t seconds); - void set_reboot_timeout(uint32_t seconds); - void set_srctime(time::RealTimeClock *srctime); + void set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } + void set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } + void set_srctime(time::RealTimeClock *srctime) { this->srctime_ = srctime; } #ifdef USE_BINARY_SENSOR - void set_status_sensor(binary_sensor::BinarySensor *sensor); - void set_enabled_sensor(binary_sensor::BinarySensor *sensor); + void set_status_sensor(binary_sensor::BinarySensor *sensor) { this->status_sensor_ = sensor; } + void set_enabled_sensor(binary_sensor::BinarySensor *sensor) { this->enabled_sensor_ = sensor; } #endif #ifdef USE_SENSOR - void set_handshake_sensor(sensor::Sensor *sensor); + void set_handshake_sensor(sensor::Sensor *sensor) { this->handshake_sensor_ = sensor; } #endif #ifdef USE_TEXT_SENSOR - void set_address_sensor(text_sensor::TextSensor *sensor); + void set_address_sensor(text_sensor::TextSensor *sensor) { this->address_sensor_ = sensor; } #endif /// Block the setup step until peer is connected. - void disable_auto_proceed(); + void disable_auto_proceed() { this->proceed_allowed_ = false; } /// Enable the WireGuard component. void enable(); @@ -93,7 +93,7 @@ class Wireguard final : public PollingComponent { void publish_enabled_state(); /// Return if the WireGuard component is or is not enabled. - bool is_enabled(); + bool is_enabled() { return this->enabled_; } bool is_peer_up() const; time_t get_latest_handshake() const; From b83ce91528c4c99043e0dee42b0e28ce24375c0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:03 -0500 Subject: [PATCH 360/597] [display] Inline the trivial DisplayPage setters and page navigation helpers (#18639) --- esphome/components/display/display.cpp | 6 ------ esphome/components/display/display.h | 9 ++++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 115adf503a..c2d45dbb60 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -685,9 +685,6 @@ void Display::show_page(DisplayPage *page) { } } -void Display::show_next_page() { this->page_->show_next(); } -void Display::show_prev_page() { this->page_->show_prev(); } - void Display::do_update_() { if (this->auto_clear_enabled_) { this->clear(); @@ -892,9 +889,6 @@ void DisplayPage::show_prev() { this->prev_->show(); } -void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; } -void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; } -void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &DisplayPage::get_writer() const { return this->writer_; } const LogString *text_align_to_string(TextAlign textalign) { diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 3a136937f6..a9ffda422d 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -802,9 +802,9 @@ class DisplayPage final { void show(); void show_next(); void show_prev(); - void set_parent(Display *parent); - void set_prev(DisplayPage *prev); - void set_next(DisplayPage *next); + void set_parent(Display *parent) { this->parent_ = parent; } + void set_prev(DisplayPage *prev) { this->prev_ = prev; } + void set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &get_writer() const; protected: @@ -814,6 +814,9 @@ class DisplayPage final { DisplayPage *next_{nullptr}; }; +inline void Display::show_next_page() { this->page_->show_next(); } +inline void Display::show_prev_page() { this->page_->show_prev(); } + template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) From a63c3bc0c7f9eab2b08def454b89223480fcf9ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:31 -0500 Subject: [PATCH 361/597] [valve] Inline the trivial Valve and ValveCall accessors (#18641) --- esphome/components/valve/valve.cpp | 7 ------- esphome/components/valve/valve.h | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 8fccd1e6d6..d8fb18b1b7 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -120,10 +120,6 @@ ValveCall &ValveCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool ValveCall::get_stop() const { return this->stop_; } - -ValveCall Valve::make_call() { return {this}; } - void Valve::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); @@ -162,9 +158,6 @@ optional Valve::restore_state_() { return recovered; } -bool Valve::is_fully_open() const { return this->position == VALVE_OPEN; } -bool Valve::is_fully_closed() const { return this->position == VALVE_CLOSED; } - ValveCall ValveRestoreState::to_call(Valve *valve) { auto call = valve->make_call(); call.set_position(this->position); diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index c6cdf07096..183680e5e4 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -47,7 +47,7 @@ class ValveCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_toggle() const; protected: @@ -114,7 +114,7 @@ class Valve : public EntityBase { float position; /// Construct a new valve call used to control the valve. - ValveCall make_call(); + ValveCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -130,9 +130,9 @@ class Valve : public EntityBase { virtual ValveTraits get_traits() = 0; /// Helper method to check if the valve is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == VALVE_OPEN; } /// Helper method to check if the valve is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == VALVE_CLOSED; } protected: friend ValveCall; From 435d5226838d8f2818d637f1a284f4f85c214295 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:06:47 -0500 Subject: [PATCH 362/597] [text] Inline the trivial Text publish_state forwarding overloads (#18642) --- esphome/components/text/text.cpp | 4 ---- esphome/components/text/text.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 032ea468e6..a1df6286c7 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -8,10 +8,6 @@ namespace esphome::text { static const char *const TAG = "text"; -void Text::publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } - -void Text::publish_state(const char *state) { this->publish_state(state, strlen(state)); } - void Text::publish_state(const char *state, size_t len) { this->set_has_state(true); // Only assign if changed to avoid heap allocation diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index eb6a68f998..54afb8db8f 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -23,8 +23,8 @@ class Text : public EntityBase { std::string state; TextTraits traits; - void publish_state(const std::string &state); - void publish_state(const char *state); + void publish_state(const std::string &state) { this->publish_state(state.data(), state.size()); } + void publish_state(const char *state) { this->publish_state(state, strlen(state)); } void publish_state(const char *state, size_t len); /// Instantiate a TextCall object to modify this text component's state. From 01ad424d12bc6c376e695084c078e9cb8d5c54fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:03 -0500 Subject: [PATCH 363/597] [datetime] Inline the trivial make_call helpers (#18643) --- esphome/components/datetime/date_entity.cpp | 2 -- esphome/components/datetime/date_entity.h | 2 ++ esphome/components/datetime/datetime_entity.cpp | 2 -- esphome/components/datetime/datetime_entity.h | 2 ++ esphome/components/datetime/time_entity.cpp | 2 -- esphome/components/datetime/time_entity.h | 2 ++ 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 997aec3f69..b99b89259f 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -37,8 +37,6 @@ void DateEntity::publish_state() { #endif } -DateCall DateEntity::make_call() { return DateCall(this); } - void DateCall::validate_() { if (this->year_.has_value() && (this->year_ < 1970 || this->year_ > 3000)) { ESP_LOGE(TAG, "Year must be between 1970 and 3000"); diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 9b86c12228..93ce1411f8 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -96,6 +96,8 @@ class DateCall { optional day_; }; +inline DateCall DateEntity::make_call() { return DateCall(this); } + template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index a8e00d6eb3..8f180fd081 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -53,8 +53,6 @@ void DateTimeEntity::publish_state() { #endif } -DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } - ESPTime DateTimeEntity::state_as_esptime() const { ESPTime obj; obj.year = this->year_; diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 159e4ccc6f..fec620b5ba 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,6 +121,8 @@ class DateTimeCall { optional second_; }; +inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } + template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index 1cc9eaf2fb..da4c9eb31e 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -33,8 +33,6 @@ void TimeEntity::publish_state() { #endif } -TimeCall TimeEntity::make_call() { return TimeCall(this); } - void TimeCall::validate_() { if (this->hour_.has_value() && this->hour_ > 23) { ESP_LOGE(TAG, "Hour must be between 0 and 23"); diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 643f4bd176..736e26f4a7 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,6 +98,8 @@ class TimeCall { optional second_; }; +inline TimeCall TimeEntity::make_call() { return TimeCall(this); } + template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) From f24b731f9510815fe164e975b7e4a4f4605cd45e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:14 -0500 Subject: [PATCH 364/597] [infrared] Inline the trivial make_call helper (#18644) --- esphome/components/infrared/infrared.cpp | 2 -- esphome/components/infrared/infrared.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b97995a96..5a909738c6 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -75,8 +75,6 @@ void Infrared::dump_config() { YESNO(this->traits_.get_supports_receiver())); } -InfraredCall Infrared::make_call() { return InfraredCall(this); } - void Infrared::control(const InfraredCall &call) { if (this->transmitter_ == nullptr) { ESP_LOGW(TAG, "No transmitter configured"); diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index 6d91c97cce..b6863e37ce 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -134,7 +134,7 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote const InfraredTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - InfraredCall make_call(); + InfraredCall make_call() { return InfraredCall(this); } /// Get capability flags for this infrared instance uint32_t get_capability_flags() const; From f0651e5c9b2ae24c33256819dd78eedce73c45a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:07:34 -0500 Subject: [PATCH 365/597] [radio_frequency] Inline the trivial make_call helper (#18645) --- esphome/components/radio_frequency/radio_frequency.cpp | 2 -- esphome/components/radio_frequency/radio_frequency.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 3e0a905737..61e7feb9af 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -81,8 +81,6 @@ void RadioFrequency::dump_config() { } } -RadioFrequencyCall RadioFrequency::make_call() { return RadioFrequencyCall(this); } - uint32_t RadioFrequency::get_capability_flags() const { uint32_t flags = 0; if (this->traits_.get_supports_transmitter()) diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 7dfd2dd77e..8782c255f0 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -157,7 +157,7 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: const RadioFrequencyTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - RadioFrequencyCall make_call(); + RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } /// Get capability flags for this radio frequency instance uint32_t get_capability_flags() const; From cd536817876caff90c750fa9e17b2bdbb181034f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:51:39 -0500 Subject: [PATCH 366/597] [wifi] Inline the remaining trivial WiFiAP and WiFiComponent accessors (#18617) --- esphome/components/wifi/wifi_component.cpp | 38 ------------------ esphome/components/wifi/wifi_component.h | 46 +++++++++++----------- 2 files changed, 24 insertions(+), 60 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ed5fc9094..b8a31f97a3 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -618,8 +618,6 @@ static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) { } #endif -float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; } - void WiFiComponent::setup() { this->wifi_pre_setup_(); @@ -931,10 +929,6 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } -#ifdef USE_WIFI_11KV_SUPPORT -void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } -void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; } -#endif network::IPAddresses WiFiComponent::get_ip_addresses() { if (this->has_sta()) return this->wifi_sta_ip_addresses(); @@ -1327,8 +1321,6 @@ void WiFiComponent::disable() { this->wifi_mode_(false, false); } -bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } - void WiFiComponent::start_scanning() { this->action_started_ = millis(); ESP_LOGD(TAG, "Starting scan"); @@ -2196,7 +2188,6 @@ void WiFiComponent::retry_connect() { } } -void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) @@ -2204,8 +2195,6 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } -void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; } - bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2324,33 +2313,6 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif -void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } -void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); } -void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } -void WiFiAP::clear_bssid() { this->bssid_ = {}; } -void WiFiAP::set_password(const std::string &password) { - this->password_ = CompactString(password.c_str(), password.size()); -} -void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); } -#ifdef USE_WIFI_WPA2_EAP -void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } -#endif -void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; } -void WiFiAP::clear_channel() { this->channel_ = 0; } -#ifdef USE_WIFI_MANUAL_IP -void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } -#endif -void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } -const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } -bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } -#ifdef USE_WIFI_WPA2_EAP -const optional &WiFiAP::get_eap() const { return this->eap_; } -#endif -#ifdef USE_WIFI_MANUAL_IP -const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } -#endif -bool WiFiAP::get_hidden() const { return this->hidden_; } - WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c54fbc004b..07d4ff23c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #ifdef USE_LIBRETINY @@ -261,38 +262,38 @@ class WiFiAP { friend class WiFiScanResult; public: - void set_ssid(const std::string &ssid); - void set_ssid(const char *ssid); + void set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } + void set_ssid(const char *ssid) { this->set_ssid(StringRef(ssid)); } void set_ssid(StringRef ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } - void set_bssid(const bssid_t &bssid); - void clear_bssid(); - void set_password(const std::string &password); - void set_password(const char *password); + void set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } + void clear_bssid() { this->bssid_ = {}; } + void set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); } + void set_password(const char *password) { this->set_password(StringRef(password)); } void set_password(StringRef password) { this->password_ = CompactString(password.c_str(), password.size()); } #ifdef USE_WIFI_WPA2_EAP - void set_eap(optional eap_auth); + void set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif // USE_WIFI_WPA2_EAP - void set_channel(uint8_t channel); - void clear_channel(); + void set_channel(uint8_t channel) { this->channel_ = channel; } + void clear_channel() { this->channel_ = 0; } void set_priority(int8_t priority) { priority_ = priority; } #ifdef USE_WIFI_MANUAL_IP - void set_manual_ip(optional manual_ip); + void set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } #endif - void set_hidden(bool hidden); + void set_hidden(bool hidden) { this->hidden_ = hidden; } StringRef get_ssid() const { return this->ssid_.ref(); } StringRef get_password() const { return this->password_.ref(); } - const bssid_t &get_bssid() const; - bool has_bssid() const; + const bssid_t &get_bssid() const { return this->bssid_; } + bool has_bssid() const { return this->bssid_ != bssid_t{}; } #ifdef USE_WIFI_WPA2_EAP - const optional &get_eap() const; + const optional &get_eap() const { return this->eap_; } #endif // USE_WIFI_WPA2_EAP uint8_t get_channel() const { return this->channel_; } bool has_channel() const { return this->channel_ != 0; } int8_t get_priority() const { return priority_; } #ifdef USE_WIFI_MANUAL_IP - const optional &get_manual_ip() const; + const optional &get_manual_ip() const { return this->manual_ip_; } #endif - bool get_hidden() const; + bool get_hidden() const { return this->hidden_; } protected: CompactString ssid_; @@ -442,6 +443,7 @@ class WiFiComponent final : public Component { void set_sta(const WiFiAP &ap); // Returns a copy of the currently selected AP configuration WiFiAP get_sta() const; + // init_sta/add_sta kept out of line: inlining them into the generated setup() grows flash void init_sta(size_t count); void add_sta(const WiFiAP &ap); void clear_sta(); @@ -461,7 +463,7 @@ class WiFiComponent final : public Component { void enable(); void disable(); - bool is_disabled(); + bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } void start_scanning(); void check_scanning_finished(); void start_connecting(const WiFiAP &ap); @@ -472,7 +474,7 @@ class WiFiComponent final : public Component { void retry_connect(); - void set_reboot_timeout(uint32_t reboot_timeout); + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool is_connected() const { return this->connected_; } @@ -492,7 +494,7 @@ class WiFiComponent final : public Component { void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; } #endif - void set_passive_scan(bool passive); + void set_passive_scan(bool passive) { this->passive_scan_ = passive; } void save_wifi_sta(const std::string &ssid, const std::string &password); void save_wifi_sta(const char *ssid, const char *password); @@ -506,7 +508,7 @@ class WiFiComponent final : public Component { void dump_config() override; void restart_adapter(); /// WIFI setup_priority. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::WIFI; } /// Reconnect WiFi if required. void loop() override; @@ -515,8 +517,8 @@ class WiFiComponent final : public Component { bool is_ap_active() const { return this->ap_started_; } #ifdef USE_WIFI_11KV_SUPPORT - void set_btm(bool btm); - void set_rrm(bool rrm); + void set_btm(bool btm) { this->btm_ = btm; } + void set_rrm(bool rrm) { this->rrm_ = rrm; } #endif network::IPAddress get_dns_address(int num); From 5b3a6c05bf40a5776da603d422495e345363da3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 21:59:47 -0500 Subject: [PATCH 367/597] [core] Remove deprecated esp_log_vprintf_ flash-string overload (#18377) --- esphome/core/log.cpp | 10 ---------- esphome/core/log.h | 5 ----- 2 files changed, 15 deletions(-) diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 0da457adec..9fcddfeff6 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -60,16 +60,6 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form #endif } -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Remove before 2026.9.0 -void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { -#ifdef USE_LOGGER - ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); - logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); -#endif -} -#endif - #ifdef USE_ESP32 int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER diff --git a/esphome/core/log.h b/esphome/core/log.h index 72e06cabac..272e516808 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -68,11 +68,6 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, . void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Remove before 2026.9.0 -__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_( - int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); -#endif #if defined(USE_ESP32) int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif From ab45ab316a0190cf898e436a345e2a20a5f15c42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:02 -0500 Subject: [PATCH 368/597] [core] Remove deprecated entity_base getters (#18375) --- esphome/core/entity_base.cpp | 40 ------------------------------- esphome/core/entity_base.h | 46 ------------------------------------ 2 files changed, 86 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index fc6ac503b5..21a5fc3706 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::spandevice_class_idx_)); -#else - return StringRef(entity_device_class_lookup(0)); -#endif -} -std::string EntityBase::get_device_class() const { -#ifdef USE_ENTITY_DEVICE_CLASS - return std::string(entity_device_class_lookup(this->device_class_idx_)); -#else - return std::string(entity_device_class_lookup(0)); -#endif -} -#endif // !USE_ESP8266 - // Entity unit of measurement (from index) StringRef EntityBase::get_unit_of_measurement_ref() const { #ifdef USE_ENTITY_UNIT_OF_MEASUREMENT @@ -106,10 +88,6 @@ StringRef EntityBase::get_unit_of_measurement_ref() const { return StringRef(entity_uom_lookup(0)); #endif } -std::string EntityBase::get_unit_of_measurement() const { - return std::string(this->get_unit_of_measurement_ref().c_str()); -} - // Entity icon — buffer-based API for PROGMEM safety on ESP8266 const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { #ifdef USE_ENTITY_ICON @@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::spanicon_idx_)); -#else - return StringRef(entity_icon_lookup(0)); -#endif -} -std::string EntityBase::get_icon() const { -#ifdef USE_ENTITY_ICON - return std::string(entity_icon_lookup(this->icon_idx_)); -#else - return std::string(entity_icon_lookup(0)); -#endif -} -#endif // !USE_ESP8266 - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5f2e173d8d..f38e30bf52 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -109,60 +109,14 @@ class EntityBase { // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_device_class_to(std::span buffer) const; -#ifdef USE_ESP8266 - // On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed - // directly as const char*. Use get_device_class_to() with a stack buffer instead. - template StringRef get_device_class_ref() const { - static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). " - "Use get_device_class_to() with a stack buffer."); - return StringRef(""); - } - template std::string get_device_class() const { - static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). " - "Use get_device_class_to() with a stack buffer."); - return ""; - } -#else - // Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM. - ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - StringRef get_device_class_ref() const; - ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - std::string get_device_class() const; -#endif // Get unit of measurement as StringRef (from packed index) StringRef get_unit_of_measurement_ref() const; - /// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref()) - ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be " - "removed in ESPHome 2026.9.0", - "2026.3.0") - std::string get_unit_of_measurement() const; // Get this entity's icon into a stack buffer. // On ESP32: returns pointer to PROGMEM string directly (buffer unused). // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_icon_to(std::span buffer) const; -#ifdef USE_ESP8266 - // On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed - // directly as const char*. Use get_icon_to() with a stack buffer instead. - template StringRef get_icon_ref() const { - static_assert(sizeof(T) == 0, - "get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); - return StringRef(""); - } - template std::string get_icon() const { - static_assert(sizeof(T) == 0, - "get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); - return ""; - } -#else - // Deprecated: use get_icon_to() instead. Icons are in PROGMEM. - ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - StringRef get_icon_ref() const; - ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") - std::string get_icon() const; -#endif - #ifdef USE_DEVICES // Get this entity's device id uint32_t get_device_id() const { From b115813fbe2880a3e7ffbc2e4725e208c589d97b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:17 -0500 Subject: [PATCH 369/597] [esp32] Report abort and task watchdog panics correctly in crash handler (#18575) --- esphome/components/esp32/crash_handler.cpp | 61 +++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b61dad7386..6f65243aaa 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. static constexpr uint32_t CRASH_DATA_VERSION = 4; +#if CONFIG_IDF_TARGET_ARCH_XTENSA +// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's +// cause/vaddr slots were never written (not a real exception frame). +static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +// Synchronous mcause exception codes are small and have no interrupt bit; +// anything else in a non-pseudo record is a stale slot. +static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32; +#endif struct RawCrashData { uint32_t version; uint32_t magic; @@ -198,10 +207,28 @@ void crash_handler_clear() { s_raw_crash_data.magic = 0; } +// Whether the cause slot was written by a real exception frame. +static bool cause_slot_was_written() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT; +#else + return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT; +#endif +} + // Look up the exception cause as a human-readable string. // Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays // not exposed via any public API. static const char *get_exception_reason() { + uint8_t exception = s_raw_crash_data.exception; + if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) { + // Abort-class panics carry no cause register + return nullptr; + } + if (!cause_slot_was_written()) { + // Garbage from old-build or corrupt records; report just the type + return nullptr; + } #if CONFIG_IDF_TARGET_ARCH_XTENSA if (s_raw_crash_data.pseudo_excause) { // SoC-level panic: watchdog, cache error, etc. @@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL"; static const char *const FAULT_ADDR_REG_LOWER = "mtval"; #endif -// Whether the fault address is meaningful — real CPU faults only, not -// aborts/watchdogs or SoC-level pseudo exceptions. +// Whether the fault address is meaningful: real CPU faults with a validly +// written frame only. static bool has_fault_addr() { - return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause; + return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause && + cause_slot_was_written(); } // The record was captured by a different firmware build (it survives soft @@ -458,6 +486,10 @@ void crash_handler_log() { // into NOINIT memory before the normal panic handler runs. // extern "C" { +// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an +// abort; weak so builds without the task watchdog still link. +extern bool g_twdt_isr __attribute__((weak)); + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) // Names are mandated by the --wrap linker mechanism extern void __real_esp_panic_handler(panic_info_t *info); @@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; s_raw_crash_data.crashed_core = (uint8_t) info->core; + if (g_panic_abort) { + // IDF reclassifies to ABORT only inside esp_panic_handler(), after this + // wrapper captured info->exception; correct it here. TWDT is our own + // distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is + // not stored; the symbolized backtrace already identifies the site. + bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr; + s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT); + } // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot s_raw_crash_data.cause = 0; s_raw_crash_data.fault_addr = 0; @@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; - s_raw_crash_data.cause = xt_frame->exccause; - s_raw_crash_data.fault_addr = xt_frame->excvaddr; + if (!g_panic_abort) { + // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots + // never wrote them and abort() traps describe only the synthetic trap. + s_raw_crash_data.cause = xt_frame->exccause; + s_raw_crash_data.fault_addr = xt_frame->excvaddr; + } s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } @@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // RISC-V: capture MEPC + RA, then scan stack for code addresses if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; - s_raw_crash_data.cause = rv_frame->mcause; - s_raw_crash_data.fault_addr = rv_frame->mtval; + if (!g_panic_abort) { + // See the Xtensa branch: abort-class frames carry no valid cause/vaddr. + s_raw_crash_data.cause = rv_frame->mcause; + s_raw_crash_data.fault_addr = rv_frame->mtval; + } s_raw_crash_data.backtrace_count = capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } From f3cdefce210b9b418da3487ff57c1421bb779137 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:38 -0500 Subject: [PATCH 370/597] [wifi] Remove deprecated wifi_ssid() (#18378) --- esphome/components/wifi/wifi_component.h | 3 --- esphome/components/wifi/wifi_component_esp8266.cpp | 10 ---------- esphome/components/wifi/wifi_component_esp_idf.cpp | 12 ------------ esphome/components/wifi/wifi_component_libretiny.cpp | 1 - esphome/components/wifi/wifi_component_pico_w.cpp | 1 - 5 files changed, 27 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 07d4ff23c6..ada7be4ba4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -552,9 +552,6 @@ class WiFiComponent final : public Component { void set_sta_priority(bssid_t bssid, int8_t priority); network::IPAddresses wifi_sta_ip_addresses(); - // Remove before 2026.9.0 - ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0") - std::string wifi_ssid(); /// Write SSID to buffer without heap allocation. /// Returns pointer to buffer, or empty string if not connected. const char *wifi_ssid_to(std::span buffer); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index acaa94b13c..005d655d88 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -944,16 +944,6 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { - struct station_config conf {}; - if (!wifi_station_get_config(&conf)) { - return ""; - } - // conf.ssid is uint8[32], not null-terminated if full - auto *ssid_s = reinterpret_cast(conf.ssid); - size_t len = strnlen(ssid_s, sizeof(conf.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { struct station_config conf {}; if (!wifi_station_get_config(&conf)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 24cb060edb..32d46887b6 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1237,18 +1237,6 @@ bssid_t WiFiComponent::wifi_bssid() { std::copy(info.bssid, info.bssid + 6, bssid.begin()); return bssid; } -std::string WiFiComponent::wifi_ssid() { - wifi_ap_record_t info{}; - esp_err_t err = esp_wifi_sta_get_ap_info(&info); - if (err != ESP_OK) { - // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) - ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); - return ""; - } - auto *ssid_s = reinterpret_cast(info.ssid); - size_t len = strnlen(ssid_s, sizeof(info.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { wifi_ap_record_t info{}; esp_err_t err = esp_wifi_sta_get_ap_info(&info); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 66c397a8ad..e3c08416e8 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -762,7 +762,6 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } const char *WiFiComponent::wifi_ssid_to(std::span buffer) { #ifdef USE_BK72XX LinkStatusTypeDef link_status{}; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 69af9e9a4e..325bcf2652 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -265,7 +265,6 @@ bssid_t WiFiComponent::wifi_bssid() { bssid[i] = raw_bssid[i]; return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } const char *WiFiComponent::wifi_ssid_to(std::span buffer) { // TODO: Find direct CYW43 API to avoid Arduino String allocation String ssid = WiFi.SSID(); From 8899713ef97229f881ab8694802ef03a9290c65a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:00:53 -0500 Subject: [PATCH 371/597] [core] Remove deprecated gamma_correct and gamma_uncorrect (#18376) --- esphome/core/helpers.cpp | 17 ----------------- esphome/core/helpers.h | 9 --------- 2 files changed, 26 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a276020be4..ded8051df8 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector // Colors -float gamma_correct(float value, float gamma) { - if (value <= 0.0f) - return 0.0f; - if (gamma <= 0.0f) - return value; - - return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0 -} -float gamma_uncorrect(float value, float gamma) { - if (value <= 0.0f) - return 0.0f; - if (gamma <= 0.0f) - return value; - - return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0 -} - void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) { float max_color_value = std::max({red, green, blue}); float min_color_value = std::min({red, green, blue}); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5a9c120b84..b13d92ccce 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector /// @name Colors ///@{ -/// Applies gamma correction of \p gamma to \p value. -// Remove before 2026.9.0 -ESPDEPRECATED("Use LightState::gamma_correct_lut() instead. Removed in 2026.9.0.", "2026.3.0") -float gamma_correct(float value, float gamma); -/// Reverts gamma correction of \p gamma to \p value. -// Remove before 2026.9.0 -ESPDEPRECATED("Use LightState::gamma_uncorrect_lut() instead. Removed in 2026.9.0.", "2026.3.0") -float gamma_uncorrect(float value, float gamma); - /// Convert \p red, \p green and \p blue (all 0-1) values to \p hue (0-360), \p saturation (0-1) and \p value (0-1). void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value); /// Convert \p hue (0-360), \p saturation (0-1) and \p value (0-1) to \p red, \p green and \p blue (all 0-1). From 160d8b8f0ccdb5362daf2b144131fe5e35355991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:05 -0500 Subject: [PATCH 372/597] [web_server_idf] Remove deprecated AsyncWebServerRequest::url() (#18382) --- esphome/components/web_server_idf/web_server_idf.h | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index baa55898bb..6469b4c564 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -117,12 +117,6 @@ class AsyncWebServerRequest { /// Write URL (without query string) to buffer, returns StringRef pointing to buffer. /// URL is decoded (e.g., %20 -> space). StringRef url_to(std::span buffer) const; - // Remove before 2026.9.0 - ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0") - std::string url() const { - char buffer[URL_BUF_SIZE]; - return std::string(this->url_to(buffer)); - } // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } From b2440cb655f794ec7a2d3e83f87fea0d99fc8ed8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:22 -0500 Subject: [PATCH 373/597] [modbus] Remove deprecated waiting_for_response() (#18381) --- esphome/components/modbus/modbus.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index bb303c43a8..e5cbba88ec 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -618,9 +618,6 @@ class ModbusClientDevice { inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !this->ready_for_immediate_send(); } bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } protected: From 02da5c6484ecc478f80617ed956c9337dd42f653 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:02:41 -0500 Subject: [PATCH 374/597] [ethernet] Remove deprecated get_eth_mac_address_pretty() (#18379) --- esphome/components/ethernet/ethernet_component.h | 3 --- esphome/components/ethernet/ethernet_component_esp32.cpp | 5 ----- esphome/components/ethernet/ethernet_component_rp2.cpp | 5 ----- 3 files changed, 13 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2da070b5e0..1482e7a828 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -159,9 +159,6 @@ class EthernetComponent final : public Component { const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); - // Remove before 2026.9.0 - ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") - std::string get_eth_mac_address_pretty(); const char *get_eth_mac_address_pretty_into_buffer(std::span buf); eth_duplex_t get_duplex_mode(); eth_speed_t get_link_speed(); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 4af2d5f93c..069478e70c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -928,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 7f4db4fab7..94d84cc891 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { } } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; From d1f065671eab8fcfd25dae8106beedbcc0499452 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:21:04 -0500 Subject: [PATCH 375/597] [http_request] Abort OTA backend when update fails before first write (#18581) --- .../http_request/ota/ota_http_request.cpp | 17 +++++++++-------- .../http_request/ota/ota_http_request.h | 3 +-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8893b96c65..7e7594c3c3 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() { } } -void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container) { - if (this->update_started_) { +void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, + bool abort_backend) { + if (abort_backend) { ESP_LOGV(TAG, "Aborting OTA backend"); backend->abort(); } @@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() { auto error_code = backend->begin(container->content_length); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "backend->begin error: %d", error_code); - this->cleanup_(std::move(backend), container); + // Nothing to abort: begin() failed, so no OTA handle was opened + this->cleanup_(std::move(backend), container, /*abort_backend=*/false); return error_code; } @@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { } else { ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error); } - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return OTA_CONNECTION_ERROR; } @@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() { md5_receive.add(buf, bufsize_or_error); // write bytes to OTA backend - this->update_started_ = true; error_code = backend->write(buf, bufsize_or_error); if (error_code != ota::OTA_RESPONSE_OK) { // error code explanation available at // https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code, container->get_bytes_read() - bufsize_or_error, container->content_length); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } } @@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { this->md5_computed_ = md5_receive_str; if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) { ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str()); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH; } else { backend->set_update_md5(md5_receive_str); @@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { error_code = backend->end(); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index a706331d9a..9bb748f175 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< void flash(); protected: - void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container); + void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, bool abort_backend); uint8_t do_ota_(); std::string get_url_with_auth_(const std::string &url); bool http_get_md5_(); @@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< std::string username_{}; std::string url_{}; int status_ = -1; - bool update_started_ = false; static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size }; From e697a40fda84887373d1ab3ba77bb3af64da8560 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:06 -0500 Subject: [PATCH 376/597] [core] Register the OTA component in dummy_main like its siblings (#18666) --- tests/dummy_main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index 6fa0c08aa3..228d54ef01 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -29,6 +29,7 @@ void setup() { auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT ota->set_port(8266); + App.register_component_(ota); App.setup(); } From 33484108a982678208a9619d03e67d691df49f03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:24 -0500 Subject: [PATCH 377/597] [core] Replace a damaged existing file in write_file_if_changed (#18665) --- esphome/helpers.py | 13 ++++++++++++- tests/unit_tests/test_helpers.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 9b2a461ccd..7aa1a9a88c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -552,7 +552,18 @@ def write_file_if_changed(path: Path, text: str) -> bool: """ src_content = None if path.is_file(): - src_content = read_file(path) + try: + src_content = path.read_text(encoding="utf-8") + except UnicodeDecodeError as err: + # Replace a damaged file rather than abort the regeneration that + # fixes it; an OSError may hide an intact file, so it still raises + _LOGGER.warning("Replacing damaged file %s: %s", path, err) + with suppress(OSError): + path.unlink(missing_ok=True) + except OSError as err: + from esphome.core import EsphomeError + + raise EsphomeError(f"Error reading file {path}: {err}") from err if src_content == text: return False write_file(path, text) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 6e00e5b80f..eaa7d5a8dc 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -253,6 +253,31 @@ class Test_write_file_if_changed: assert dst.read_text() == text + def test_damaged_existing_file_is_replaced( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ): + """A non-UTF-8 existing file is logged and overwritten.""" + dst = tmp_path / "generated.txt" + dst.write_bytes(b"\xff\xfe") + + assert helpers.write_file_if_changed(dst, "fresh content") is True + + assert dst.read_text(encoding="utf-8") == "fresh content" + assert "Replacing damaged file" in caplog.text + + def test_unreadable_existing_file_still_raises(self, tmp_path: Path): + """An OSError on the comparison read still raises EsphomeError.""" + dst = tmp_path / "generated.txt" + dst.write_text("intact") + + with ( + patch.object(Path, "read_text", side_effect=OSError("permission denied")), + pytest.raises(EsphomeError, match="Error reading file"), + ): + helpers.write_file_if_changed(dst, "fresh content") + + assert dst.exists() + def test_dst_does_not_exist(self, tmp_path: Path): text = "A files are unique.\n" dst = tmp_path / "file-a.txt" From e7574a574b6d5e2303df20edb73e64474ef23113 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:04:48 -0500 Subject: [PATCH 378/597] [ota] Restore lazy flash erase for ESP32 OTA with 64 KiB block erase (#18580) --- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/ota/ota_backend.h | 13 +++ .../components/ota/ota_backend_esp_idf.cpp | 86 +++++++++++++++---- esphome/components/ota/ota_backend_esp_idf.h | 19 +++- .../components/ota/ota_bootloader_esp_idf.cpp | 11 ++- .../components/ota/ota_signature_esp_idf.cpp | 2 +- tests/components/ota/test_erase_ahead.cpp | 41 +++++++++ 7 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 tests/components/ota/test_erase_ahead.cpp diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 9cbb25b373..74f84b71fb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -398,7 +398,7 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // begin() may block for a few seconds while it locks flash. + // begin() returns quickly; flash sectors are erased incrementally during write(). error_code = this->backend_->begin(ota_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index aa93df60a5..1c24fc320a 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -66,6 +66,19 @@ enum OTAResponseTypes { */ bool version_is_older(const char *candidate, const char *reference); +// 64 KiB flash block; the erase granularity the ESP-IDF backend erases ahead with. +static constexpr size_t OTA_BLOCK_ERASE_SIZE = 64 * 1024; + +/** Target erased watermark for lazy block erase-ahead. + * + * Rounds the write end offset up to a block boundary, clamped to the partition + * size. Platform-independent so the arithmetic is host-testable. + */ +constexpr size_t next_erase_end(size_t write_end, size_t partition_size) { + const size_t rounded = (write_end + OTA_BLOCK_ERASE_SIZE - 1) & ~(OTA_BLOCK_ERASE_SIZE - 1); + return rounded < partition_size ? rounded : partition_size; +} + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index eb23ad82dd..f33f37bbeb 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -7,7 +7,7 @@ #include "esphome/core/log.h" #include -#include +#include #include #ifdef USE_OTA_DOWNGRADE_PROTECTION #include @@ -60,27 +60,38 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - // esp_ota_begin() erases the destination region, which blocks loopTask and - // scales with the erase size -- a fixed watchdog overruns on large OTA slots. - // An unknown size (0, e.g. web_server uploads) erases the whole partition, so - // budget against the bytes actually erased. ~10ms/KiB (conservative - // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still - // resets rather than hanging forever. - size_t erase_size = image_size; - if (erase_size == 0 || erase_size > this->partition_->size) { - erase_size = this->partition_->size; + // Both lazy-erase paths below replace esp_ota_begin()'s blocking full erase. + // Size check replaces the one that erase performed (0 = unknown size, + // e.g. web_server uploads). + if (image_size != 0 && image_size > this->partition_->size) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } - const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; - watchdog::WatchdogManager watchdog(erase_budget_ms); - esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); + this->written_ = 0; + esp_err_t err; +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + this->erased_end_ = 0; + // Unlike esp_ota_begin(), esp_ota_resume() does not reject a running app in + // ESP_OTA_IMG_PENDING_VERIFY; that state is unreachable here because the app + // was marked valid at boot (esp32/hal.cpp) or just above under USE_OTA_ROLLBACK. + // erase_size 0 (!= OTA_WITH_SEQUENTIAL_WRITES) means no erase; erase_ahead_() handles it + err = esp_ota_resume(this->partition_, 0, 0, &this->update_handle_); +#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // esp_ota_begin() does this on IDF 5.5+; esp_ota_resume() does not. Prevents + // booting a half-written slot after a crash mid-OTA. Not available on the + // 5.3.3/5.4.2 backports, whose esp_ota_begin() did not invalidate either. + if (err == ESP_OK) { + esp_ota_invalidate_inactive_ota_data_slot(); + } +#endif +#else + err = esp_ota_begin(this->partition_, OTA_WITH_SEQUENTIAL_WRITES, &this->update_handle_); +#endif if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err); + ESP_LOGE(TAG, "OTA begin failed (err=0x%X)", err); esp_ota_abort(this->update_handle_); this->update_handle_ = 0; - if (err == ESP_ERR_INVALID_SIZE) { - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { + if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) { // This error appears with 1 factory and 1 ota partition @@ -120,6 +131,17 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } +#endif + // Overflow can only happen on unknown-size uploads (web_server); known + // sizes were rejected in begin(). + if (this->written_ + len > this->partition_->size) { + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; + } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + OTAResponseTypes erase_result = this->erase_ahead_(len); + if (erase_result != OTA_RESPONSE_OK) { + return erase_result; + } #endif esp_err_t err = esp_ota_write(this->update_handle_, data, len); this->md5_.add(data, len); @@ -127,14 +149,40 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) { ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err); if (err == ESP_ERR_OTA_VALIDATE_FAILED) { return OTA_RESPONSE_ERROR_MAGIC; + } else if (err == ESP_ERR_INVALID_SIZE) { + // Sequential-writes fallback: IDF's lazy erase reports overflow here + return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; } else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } return OTA_RESPONSE_ERROR_UNKNOWN; } + this->written_ += len; return OTA_RESPONSE_OK; } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD +OTAResponseTypes IDFOTABackend::erase_ahead_(size_t len) { + const size_t end = this->written_ + len; + if (this->erased_end_ >= end) { + return OTA_RESPONSE_OK; + } + // Round up to a block boundary, clamped to the partition end; IDF splits the + // range into 64 KiB block erases where aligned, sector erases elsewhere. + const size_t erase_to = next_erase_end(end, this->partition_->size); + // A block erase is one uninterruptible flash op (typically ~150 ms, seconds + // on aged flash) and the transfer loop may not have fed the WDT for ~1s. + watchdog::WatchdogManager watchdog(15000); + esp_err_t err = esp_partition_erase_range(this->partition_, this->erased_end_, erase_to - this->erased_end_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_partition_erase_range failed (err=0x%X)", err); + return err == ESP_ERR_INVALID_SIZE ? OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE : OTA_RESPONSE_ERROR_WRITING_FLASH; + } + this->erased_end_ = erase_to; + return OTA_RESPONSE_OK; +} +#endif + OTAResponseTypes IDFOTABackend::end() { if (this->md5_set_) { this->md5_.calculate(); @@ -226,6 +274,10 @@ void IDFOTABackend::abort() { // or not an update is in flight. esp_ota_abort(this->update_handle_); this->update_handle_ = 0; + this->written_ = 0; +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + this->erased_end_ = 0; +#endif } } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 9dffd5429e..c991f896e8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -5,8 +5,18 @@ #include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" +#include #include +// esp_ota_resume() (IDF 5.4.2+, backported to 5.3.3) provides a no-erase OTA +// handle, letting write() block-erase 64 KiB ahead of the write cursor +// (~4x faster than the per-sector lazy erase of OTA_WITH_SEQUENTIAL_WRITES, +// used as fallback on older IDF). +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) || \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 3) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0)) +#define USE_OTA_BLOCK_ERASE_AHEAD +#endif + namespace esphome::ota { #ifdef USE_OTA_PARTITIONS @@ -54,6 +64,9 @@ class IDFOTABackend final { #endif private: +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + OTAResponseTypes erase_ahead_(size_t len); +#endif #ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY // Accept an image signed by any key the running app trusts (up to 3 blocks), // so rotation and backup keys work. Fails closed. Covers app and bootloader. @@ -62,7 +75,11 @@ class IDFOTABackend final { // Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly. md5::MD5Digest md5_{}; esp_ota_handle_t update_handle_{0}; - const esp_partition_t *partition_; + const esp_partition_t *partition_{nullptr}; + size_t written_{0}; // Bytes handed to esp_ota_write() +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + size_t erased_end_{0}; // Erased up to this partition offset; must stay >= written_ +#endif char expected_bin_md5_[32]; bool md5_set_{false}; #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 57b5529350..5a83d92689 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "ota_backend_esp_idf.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/defines.h" #ifdef USE_OTA_PARTITIONS @@ -69,12 +70,20 @@ OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() { return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY; } // Erase full size of the bootloader partition in the staging partition - // to avoid copying old data to the bootloader partition later + // to avoid copying old data to the bootloader partition later. Up to + // ESP_BOOTLOADER_SIZE of blocking erase; widen the WDT for its duration. + watchdog::WatchdogManager watchdog(15000); esp_err_t err = esp_partition_erase_range(this->partition_, 0, this->bootloader_part_->size); if (err != ESP_OK) { ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err); // No critical error, don't return } +#ifdef USE_OTA_BLOCK_ERASE_AHEAD + if (err == ESP_OK) { + // Skip re-erasing the pre-erased staging region in erase_ahead_() + this->erased_end_ = this->bootloader_part_->size; + } +#endif err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false); if (err != ESP_OK) { esp_ota_abort(this->update_handle_); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 71dcc0eb83..501d6ac241 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -211,7 +211,7 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) { bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // Verification re-hashes the full image (after esp_ota_end already did one // pass), which can approach the task WDT budget on a large app. Extend it for - // the duration, mirroring the erase budget in begin(). + // the duration, scaled to the image size over a 15 s floor. const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10; watchdog::WatchdogManager watchdog(verify_budget_ms); diff --git a/tests/components/ota/test_erase_ahead.cpp b/tests/components/ota/test_erase_ahead.cpp new file mode 100644 index 0000000000..f84dd8a85d --- /dev/null +++ b/tests/components/ota/test_erase_ahead.cpp @@ -0,0 +1,41 @@ +// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the +// erased watermark must always cover the write end, stay 64 KiB block-aligned +// until the clamp, and never exceed the partition. + +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +static constexpr size_t BLOCK = 64 * 1024; +static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size + +TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); } + +TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); } + +TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); } + +TEST(NextEraseEnd, ClampsToPartitionEnd) { + // Partition sizes are sector multiples but not always block multiples + constexpr size_t part = 27 * BLOCK + 4096; + EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part); + EXPECT_EQ(next_erase_end(part, part), part); +} + +// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for +// a write past that seed must still cover the write end. +TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); } + +TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) { + for (size_t end = 1; end <= PART; end += 4093) { + const size_t erased = next_erase_end(end, PART); + ASSERT_GE(erased, end); + ASSERT_LE(erased, PART); + // Block-aligned unless clamped at the partition end + ASSERT_TRUE(erased == PART || erased % BLOCK == 0); + } +} + +} // namespace esphome::ota::testing From cf31c08a5cc0b92c667966bf5abc55e9c0b502e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 09:05:04 -0500 Subject: [PATCH 379/597] [core] Skip copying entity automation and filter sources when unused (#18602) --- esphome/components/binary_sensor/__init__.py | 18 +++++++++ .../components/binary_sensor/automation.cpp | 12 ++++++ esphome/components/esp32/__init__.py | 8 ++++ esphome/components/esp32/gpio.cpp | 7 +++- esphome/components/esp32/gpio.py | 1 + esphome/components/ota/__init__.py | 38 +++++++++---------- esphome/components/sensor/__init__.py | 6 +++ esphome/components/text_sensor/__init__.py | 6 +++ esphome/components/uptime/sensor/__init__.py | 11 ++---- esphome/config_helpers.py | 25 ++++++++++++ esphome/core/defines.h | 3 ++ tests/components/binary_sensor/common.yaml | 16 ++++++++ tests/unit_tests/test_config_helpers.py | 24 ++++++++++++ 13 files changed, 145 insertions(+), 30 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 5800e0bd9e..1ab6f7103f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DELAY, @@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = ( async def _build_binary_sensor_automations(var, config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK): + cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER") + if config.get(CONF_ON_MULTI_CLICK): + cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER") + for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH] @@ -673,3 +679,15 @@ async def to_code(config): async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) + + +# automation.cpp only implements the click/double_click/multi_click triggers +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "automation.cpp": ( + "USE_BINARY_SENSOR_CLICK_TRIGGER", + "USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER", + ), + "filter.cpp": "USE_BINARY_SENSOR_FILTER", + } +) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index b13e4a88dd..1a3c1f7536 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -1,8 +1,13 @@ +#include "esphome/core/defines.h" +#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER) + #include "automation.h" #include "esphome/core/log.h" namespace esphome::binary_sensor { +#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + static const char *const TAG = "binary_sensor.automation"; // MultiClickTrigger timeout IDs. @@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() { this->trigger(); } +#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + +#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { if (max_length == 0) { return length >= min_length; @@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { return length >= min_length && length <= max_length; } } +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER + } // namespace esphome::binary_sensor + +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 501c2e525f..cde0cfd68b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -12,6 +12,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -3451,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state + + +# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which +# are instantiated solely by the pin schema codegen (esp32_pin_to_code) +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} +) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 4b53d3a172..74665f3126 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,4 +1,7 @@ -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// Also defines the core ISRInternalGPIOPin methods; those are only reachable +// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. +#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) #include "gpio.h" #include "esphome/core/log.h" @@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 321dd3d498..98aac209ec 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): + cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 5240db9e8f..a2e6953a16 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,6 +1,9 @@ from esphome import automation import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, @@ -171,24 +174,17 @@ _filter_backend_source_files = filter_source_files_from_platform( ) +# USE_OTA_SIGNED_VERIFICATION_MULTI_KEY is set only on ESP32/IDF; +# USE_OTA_PARTITIONS is set by the esphome OTA platform when +# allow_partition_access is enabled. +_filter_define_source_files = filter_source_files_from_defines( + { + "ota_signature_esp_idf.cpp": "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY", + "ota_bootloader_esp_idf.cpp": "USE_OTA_PARTITIONS", + "ota_partitions_esp_idf.cpp": "USE_OTA_PARTITIONS", + } +) + + def FILTER_SOURCE_FILES() -> list[str]: - files = _filter_backend_source_files() - # ota_signature_esp_idf.cpp implements multi-key OTA signature verification, - # compiled only when the esp32 component enables it (external RSA signed - # OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on - # ESP32/IDF, so this also excludes the file on every other platform. Filter - # it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened - # and parsed on every build. - if not any( - define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" - for define in CORE.defines - ): - files.append("ota_signature_esp_idf.cpp") - # ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully - # #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when - # allow_partition_access is enabled). Filter them out otherwise for the - # same reason as above. - if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines): - files.append("ota_bootloader_esp_idf.cpp") - files.append("ota_partitions_esp_idf.cpp") - return files + return _filter_backend_source_files() + _filter_define_source_files() diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6ad76046a1..79d4ce5e0c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -5,6 +5,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_B_CONSTANT +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ABOVE, @@ -1303,3 +1304,8 @@ def _lstsq(a, b): @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(sensor_ns.using) + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"filter.cpp": "USE_SENSOR_FILTER"} +) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index a3f4999a8f..29399a51b7 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_CLASS, @@ -256,3 +257,8 @@ async def text_sensor_state_to_code(config, condition_id, template_arg, args): templ = await cg.templatable(config[CONF_STATE], args, cg.std_string) cg.add(var.set_state(templ)) return var + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"filter.cpp": "USE_TEXT_SENSOR_FILTER"} +) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index debeb41444..dd76bb5a87 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, time +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, @@ -10,7 +11,6 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) -from esphome.core import CORE uptime_ns = cg.esphome_ns.namespace("uptime") UptimeSecondsSensor = uptime_ns.class_( @@ -62,9 +62,6 @@ async def to_code(config): cg.add(var.set_time(time_id)) -def FILTER_SOURCE_FILES() -> list[str]: - # uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it - # when no time component is configured. - if not any(define.name == "USE_TIME" for define in CORE.defines): - return ["uptime_timestamp_sensor.cpp"] - return [] +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"uptime_timestamp_sensor.cpp": "USE_TIME"} +) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c82c2b3dbe..60bed1537e 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -151,6 +151,31 @@ def filter_source_files_from_platform( return filter_source_files +def filter_source_files_from_defines( + files_map: dict[str, str | tuple[str, ...]], +) -> Callable[[], list[str]]: + """Helper to build a FILTER_SOURCE_FILES function from a define mapping. + + Args: + files_map: Dict mapping filename to the define name (or tuple of + define names) that keeps the file in the build; the file is + excluded when none of its defines is set for the current config. + + Returns: + Function that returns the files to exclude for the current config. + """ + + def filter_source_files() -> list[str]: + defines = {define.name for define in CORE.defines} + return [ + filename + for filename, needed in files_map.items() + if defines.isdisjoint((needed,) if isinstance(needed, str) else needed) + ] + + return filter_source_files + + def get_logger_level() -> str: """Get the configured logger level. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bb4960aec7..20aca3776f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -43,7 +43,9 @@ #define USE_ALARM_CONTROL_PANEL #define USE_AREAS #define USE_BINARY_SENSOR +#define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER +#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BUTTON #define USE_CAMERA @@ -281,6 +283,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..d0a16cc99c 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -136,3 +136,19 @@ binary_sensor: invalid_cooldown: 2s then: - logger.log: "Click with custom cooldown" + + # Test on_click and on_double_click (compiles match_interval via + # USE_BINARY_SENSOR_CLICK_TRIGGER) + - platform: template + id: click_triggers + name: "Click Triggers" + on_click: + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Clicked" + on_double_click: + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Double clicked" diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 88913c0f23..e53016dfc3 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from esphome.config_helpers import ( + filter_source_files_from_defines, filter_source_files_from_platform, frameworks_for_platforms, get_logger_level, @@ -18,6 +19,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PlatformFramework, ) +from esphome.core import Define def test_filter_source_files_from_platform_esp32() -> None: @@ -148,3 +150,25 @@ def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: } with pytest.raises(ValueError, match="unknown platform"): frameworks_for_platforms(["esp32", "not_a_platform"]) + + +def test_filter_source_files_from_defines() -> None: + """Files are excluded unless one of their defines is set.""" + files_map: dict[str, str | tuple[str, ...]] = { + "filter.cpp": "USE_SENSOR_FILTER", + "automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"), + } + filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map) + + with patch("esphome.config_helpers.CORE") as mock_core: + mock_core.defines = {Define("USE_SENSOR_FILTER")} + assert filter_func() == ["automation.cpp"] + + mock_core.defines = {Define("USE_MULTI_CLICK")} + assert filter_func() == ["filter.cpp"] + + mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")} + assert filter_func() == [] + + mock_core.defines = set() + assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"] From f741c274d577f748afc31b9156b1daecca9392cc Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:55:42 +0000 Subject: [PATCH 380/597] Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3362e43239..822eebc1f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.13.1 +aioesphomeapi==46.0.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 4efd30834575606ffc878549d7c4626c79e5eba4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 11:26:55 -0500 Subject: [PATCH 381/597] [tests] Fix flaky pty log probe test on macOS (#18681) --- tests/unit_tests/test_log.py | 48 +++++++++++++++++------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 194b38209b..40e3aa6d22 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -1,5 +1,4 @@ from collections.abc import Generator -import errno import io import logging import os @@ -178,37 +177,34 @@ def _run_probe_on_pty( output = b"" deadline = time.monotonic() + 60 try: - try: - proc = subprocess.Popen( - _probe_command(fixture_path), - stdout=follower, - stderr=follower if stderr_to_pty else subprocess.PIPE, - stdin=follower, - env=probe_env, - ) - finally: - os.close(follower) - while True: - timeout = deadline - time.monotonic() - if timeout <= 0 or not select.select([controller], [], [], timeout)[0]: - pytest.fail(f"pty probe produced no EOF in time; got {output!r}") - try: - chunk = os.read(controller, 1024) - except OSError as err: - # macOS raises EIO once the child closes its end of the pty; - # anything else is a real failure, not end-of-stream. - if err.errno != errno.EIO: - raise - break - if not chunk: - break + proc = subprocess.Popen( + _probe_command(fixture_path), + stdout=follower, + stderr=follower if stderr_to_pty else subprocess.PIPE, + stdin=follower, + env=probe_env, + ) + # The parent keeps the follower open until the child has exited and + # the controller is drained: macOS discards buffered pty output once + # the last follower closes, so closing it early loses the probe's + # output whenever the child finishes before the first read. + while proc.poll() is None: + if time.monotonic() > deadline: + pytest.fail(f"pty probe did not exit in time; got {output!r}") + if select.select([controller], [], [], 0.01)[0]: + output += os.read(controller, 4096) + # Everything the child wrote is already buffered, so drain without waiting. + while select.select([controller], [], [], 0)[0] and ( + chunk := os.read(controller, 4096) + ): output += chunk stderr_text = "" if proc.stderr is not None: stderr_text = proc.stderr.read().decode(errors="replace") proc.stderr.close() - assert proc.wait(60) == 0, stderr_text + assert proc.returncode == 0, stderr_text finally: + os.close(follower) os.close(controller) if proc is not None and proc.poll() is None: proc.kill() From dde6906f980ecff7dedf08f289edf97b6e9ef5ba Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 23 Aug 2026 11:01:34 -0700 Subject: [PATCH 382/597] [modbus_client] Add continuous option to the read and send actions (#18542) Co-authored-by: Claude --- esphome/components/modbus/__init__.py | 79 ++++++++++++++++++- esphome/components/modbus/modbus.cpp | 23 +++--- esphome/components/modbus/modbus.h | 48 +++++++---- esphome/components/modbus_client/__init__.py | 70 +++++++++++++--- .../components/modbus_client/modbus_client.h | 40 ++++++++-- .../modbus_client/test_modbus_client.py | 31 +++++++- .../modbus/modbus_client_hub_test.cpp | 40 +++++----- tests/components/modbus_client/common.yaml | 5 ++ 8 files changed, 261 insertions(+), 75 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a98591c6bc..89ffc7facf 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,17 +1,23 @@ from __future__ import annotations import logging -from typing import Any, Literal +from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_CONTINUOUS, + CONF_DISABLE_CRC, + CONF_FLOW_CONTROL_PIN, + CONF_ID, +) from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv -from esphome.types import ConfigType +from esphome.types import ConfigType, TemplateArgsType _LOGGER = logging.getLogger(__name__) @@ -48,6 +54,73 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] + +class _CommandOption(NamedTuple): + """One per-command option forwarded to the hub (modbus::CommandOptions).""" + + conf_key: str + field: str # the C++ field, and so the set_() setter name + validator: Any # the static (non-templatable) validator for the key + cpp_type: Any # the C++ type the value is generated as + default: Any + + +# Per-direction command options. Single-sourcing the schema and the setter generation here keeps +# them from drifting; the C++ side must add the matching field per the rules documented on +# CommandOptions (modbus.h). +_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { + "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], + "write": [], +} + + +def _command_options(direction: str) -> list[_CommandOption]: + try: + return _COMMAND_OPTIONS[direction] + except KeyError: + raise ValueError(f"unknown command-options direction {direction!r}") from None + + +def command_options_schema( + *, direction: Literal["read", "write"], templatable: bool = False +) -> dict[cv.Optional, Any]: + """Schema fragment for the per-command options a component forwards to the hub + (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are + direction-specific so a schema never offers an option the hub would strip (e.g. + continuous on a write); the write side has no options yet. For actions (templatable=True the + keys also accept lambdas), register the values with register_templatable_command_options(). + """ + return { + cv.Optional(option.conf_key, default=option.default): ( + cv.templatable(option.validator) if templatable else option.validator + ) + for option in _command_options(direction) + } + + +async def register_templatable_command_options( + var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str +) -> None: + """Generate the set_